feat: import timestops (#1574)

* feat: import timestops

* feat: and a page to edit it
This commit is contained in:
zk
2026-05-24 13:06:13 +01:00
committed by GitHub
parent 43963e0f62
commit dcc41adeec
26 changed files with 1085 additions and 55 deletions
@@ -0,0 +1,6 @@
CREATE TABLE import_timestop (
user_id BIGINT NOT NULL REFERENCES account(id),
import_type IMPORT_TYPE NOT NULL,
last_score_time TIMESTAMPTZ NOT NULL,
PRIMARY KEY (user_id, import_type)
);
@@ -0,0 +1,227 @@
import Card from "#components/layout/page/Card";
import ApiError from "#components/util/ApiError";
import Loading from "#components/util/Loading";
import useApiQuery from "#components/util/query/useApiQuery";
import { APIFetchV1 } from "#util/api";
import { FormatTime } from "#util/time";
import { DateTime } from "luxon";
import React, { useMemo, useState } from "react";
import { Alert, Button, Form, Table } from "react-bootstrap";
import { useQueryClient } from "react-query";
import { type UserDocument } from "tachi-common";
type ImportTimestopEntry = {
importType: string;
lastScoreTime: number | null;
};
function toDatetimeLocalValue(ms: number | null): string {
if (ms === null) {
return "";
}
return DateTime.fromMillis(ms).toFormat("yyyy-MM-dd'T'HH:mm");
}
function fromDatetimeLocalValue(value: string): number | null {
if (!value) {
return null;
}
const parsed = DateTime.fromFormat(value, "yyyy-MM-dd'T'HH:mm");
if (!parsed.isValid) {
return null;
}
return parsed.toMillis();
}
export default function UserImportTimestopsTab({ reqUser }: { reqUser: UserDocument }) {
const queryKey = `/users/${reqUser.id}/import-timestops`;
const queryClient = useQueryClient();
const { data, error } = useApiQuery<{ timestops: Array<ImportTimestopEntry> }>(queryKey);
const [busyImportType, setBusyImportType] = useState<string | null>(null);
const [draftTimes, setDraftTimes] = useState<Record<string, string>>({});
const [message, setMessage] = useState<string | null>(null);
const timestops = useMemo(() => {
if (!data) {
return [];
}
return [...data.timestops].sort((a, b) => a.importType.localeCompare(b.importType));
}, [data]);
if (error) {
return <ApiError error={error} />;
}
if (!data) {
return <Loading />;
}
const invalidate = async () => {
await queryClient.invalidateQueries([queryKey]);
};
const onReset = async (importType: string) => {
if (
!window.confirm(
`Reset the timestop for ${importType}? The next import will fetch all available scores again.`,
)
) {
return;
}
setBusyImportType(importType);
setMessage(null);
const res = await APIFetchV1(
`/users/${reqUser.id}/import-timestops`,
{
body: JSON.stringify({ importType }),
headers: { "Content-Type": "application/json" },
method: "DELETE",
},
true,
true,
);
setBusyImportType(null);
if (!res.success) {
setMessage(res.description);
return;
}
setDraftTimes((prev) => {
const next = { ...prev };
delete next[importType];
return next;
});
setMessage(`Reset timestop for ${importType}.`);
await invalidate();
};
const onSave = async (importType: string) => {
const draft =
draftTimes[importType] ??
toDatetimeLocalValue(
timestops.find((entry) => entry.importType === importType)?.lastScoreTime ?? null,
);
const lastScoreTime = fromDatetimeLocalValue(draft);
if (lastScoreTime === null) {
setMessage("Enter a valid date and time.");
return;
}
setBusyImportType(importType);
setMessage(null);
const res = await APIFetchV1(
`/users/${reqUser.id}/import-timestops`,
{
body: JSON.stringify({ importType, lastScoreTime }),
headers: { "Content-Type": "application/json" },
method: "PUT",
},
true,
true,
);
setBusyImportType(null);
if (!res.success) {
setMessage(res.description);
return;
}
setMessage(`Updated timestop for ${importType}.`);
await invalidate();
};
return (
<div className="vstack gap-3">
<Card header="Import timestops">
<p className="mb-2">
API imports track the newest score they have already imported. On the next
import, anything at or before that timestamp is skipped.
</p>
<p className="mb-0">
Reset a timestop to re-fetch all scores, or set a custom cursor if you need to
re-import from a specific point in time.
</p>
</Card>
{message && <Alert variant="info">{message}</Alert>}
<Table hover responsive striped>
<thead>
<tr>
<th>Import type</th>
<th>Current cursor</th>
<th>Set cursor</th>
<th />
</tr>
</thead>
<tbody>
{timestops.map((entry) => {
const draft =
draftTimes[entry.importType] ??
toDatetimeLocalValue(entry.lastScoreTime);
const isBusy = busyImportType === entry.importType;
return (
<tr key={entry.importType}>
<td>
<code>{entry.importType}</code>
</td>
<td>
{entry.lastScoreTime === null ? (
<span className="text-muted">Not set</span>
) : (
FormatTime(entry.lastScoreTime)
)}
</td>
<td>
<Form.Control
disabled={isBusy}
onChange={(event) =>
setDraftTimes((prev) => ({
...prev,
[entry.importType]: event.target.value,
}))
}
type="datetime-local"
value={draft}
/>
</td>
<td className="text-nowrap">
<Button
className="me-2"
disabled={isBusy}
onClick={() => void onSave(entry.importType)}
size="sm"
variant="primary"
>
Save
</Button>
<Button
disabled={isBusy || entry.lastScoreTime === null}
onClick={() => void onReset(entry.importType)}
size="sm"
variant="outline-danger"
>
Reset
</Button>
</td>
</tr>
);
})}
</tbody>
</Table>
</div>
);
}
@@ -9,9 +9,11 @@ import useApiQuery from "#components/util/query/useApiQuery";
import SelectLinkButton from "#components/util/SelectLinkButton";
import { type FailedImportDataset, type ImportDataset } from "#types/tables";
import React, { useMemo, useState } from "react";
import { Route, Switch } from "react-router-dom";
import { Route, Switch, useLocation } from "react-router-dom";
import { type ImportDocument, type ImportTrackerFailed, type UserDocument } from "tachi-common";
import UserImportTimestopsTab from "./UserImportTimestopsTab";
export default function UserImportsPage({ reqUser }: { reqUser: UserDocument }) {
useSetSubheader(
["Users", reqUser.username, "Imports"],
@@ -37,6 +39,9 @@ export default function UserImportsPage({ reqUser }: { reqUser: UserDocument })
return p;
}, [userIntent, importType]);
const location = useLocation();
const onTimestopsTab = location.pathname.endsWith("/imports/timestops");
return (
<>
<div className="d-flex justify-content-center btn-group">
@@ -46,10 +51,19 @@ export default function UserImportsPage({ reqUser }: { reqUser: UserDocument })
<SelectLinkButton to={`/u/${reqUser.username}/imports/failed`}>
Recent Failed Imports
</SelectLinkButton>
<SelectLinkButton to={`/u/${reqUser.username}/imports/timestops`}>
Timestops
</SelectLinkButton>
</div>
<Divider />
<ImportViewerOptions {...{ userIntent, setUserIntent, importType, setImportType }} />
{!onTimestopsTab && (
<>
<Divider />
<ImportViewerOptions
{...{ userIntent, setUserIntent, importType, setImportType }}
/>
</>
)}
<Divider />
<Switch>
<Route exact path={`/u/${reqUser.username}/imports`}>
@@ -59,6 +73,10 @@ export default function UserImportsPage({ reqUser }: { reqUser: UserDocument })
<Route path={`/u/${reqUser.username}/imports/failed`}>
<ViewRecentFailedImports params={params} reqUser={reqUser} />
</Route>
<Route path={`/u/${reqUser.username}/imports/timestops`}>
<UserImportTimestopsTab reqUser={reqUser} />
</Route>
</Switch>
</>
);
+1
View File
@@ -5,6 +5,7 @@ export { type priv_svc_cg_card_info_service, type default as PrivSvcCgCardInfoTa
export { type score_rederive_chart_id, type default as ScoreRederiveTable, type ScoreRederive, type NewScoreRederive, type ScoreRederiveUpdate } from './public/ScoreRederive';
export { type class_achievement_row_id, type default as ClassAchievementTable, type ClassAchievement, type NewClassAchievement, type ClassAchievementUpdate } from './public/ClassAchievement';
export { type account_id, type default as AccountTable, type Account, type NewAccount, type AccountUpdate } from './public/Account';
export { type import_timestop_import_type, type default as ImportTimestopTable, type ImportTimestop, type NewImportTimestop, type ImportTimestopUpdate } from './public/ImportTimestop';
export { type import_tracker_import_id, type default as ImportTrackerTable, type ImportTracker, type NewImportTracker, type ImportTrackerUpdate } from './public/ImportTracker';
export { type default as SvcFerSettingsTable, type SvcFerSettings, type NewSvcFerSettings, type SvcFerSettingsUpdate } from './public/SvcFerSettings';
export { type default as InviteLockTable, type InviteLock, type NewInviteLock, type InviteLockUpdate } from './public/InviteLock';
@@ -5,6 +5,7 @@ import type { default as PrivSvcCgCardInfoTable } from './PrivSvcCgCardInfo';
import type { default as ScoreRederiveTable } from './ScoreRederive';
import type { default as ClassAchievementTable } from './ClassAchievement';
import type { default as AccountTable } from './Account';
import type { default as ImportTimestopTable } from './ImportTimestop';
import type { default as ImportTrackerTable } from './ImportTracker';
import type { default as SvcFerSettingsTable } from './SvcFerSettings';
import type { default as InviteLockTable } from './InviteLock';
@@ -79,6 +80,8 @@ export default interface PublicSchema {
account: AccountTable;
import_timestop: ImportTimestopTable;
import_tracker: ImportTrackerTable;
svc_fer_settings: SvcFerSettingsTable;
@@ -0,0 +1,194 @@
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { beforeEach, describe, expect, it } from "vitest";
import {
DeleteImportTimestop,
GetImportTimestop,
ListImportTimestops,
SetImportTimestop,
SetImportTimestopManual,
} from "./timestop";
describe("GetImportTimestop", () => {
let userID: number;
beforeEach(async () => {
({ id: userID } = await seedUser());
});
it("returns null when no row exists", async () => {
const result = await GetImportTimestop(userID, "api/eag-iidx");
expect(result).toBeNull();
});
it("returns the stored date after SetImportTimestop", async () => {
const time = new Date("2025-03-15T12:00:00.000Z");
await SetImportTimestop(userID, "api/eag-iidx", time);
const result = await GetImportTimestop(userID, "api/eag-iidx");
expect(result).not.toBeNull();
expect(result!.getTime()).toBe(time.getTime());
});
it("is scoped per import type", async () => {
const time = new Date("2025-01-01T00:00:00.000Z");
await SetImportTimestop(userID, "api/eag-iidx", time);
expect(await GetImportTimestop(userID, "api/eag-sdvx")).toBeNull();
});
it("is scoped per user", async () => {
const other = await seedUser({ username: "other_user" });
const time = new Date("2025-01-01T00:00:00.000Z");
await SetImportTimestop(other.id, "api/eag-iidx", time);
expect(await GetImportTimestop(userID, "api/eag-iidx")).toBeNull();
});
});
describe("SetImportTimestop", () => {
let userID: number;
beforeEach(async () => {
({ id: userID } = await seedUser());
});
it("inserts a new row when none exists", async () => {
const time = new Date("2025-06-01T00:00:00.000Z");
await SetImportTimestop(userID, "api/flo-sdvx", time);
const row = await DB.selectFrom("import_timestop")
.select(["import_timestop.last_score_time"])
.where("import_timestop.user_id", "=", userID)
.where("import_timestop.import_type", "=", "api/flo-sdvx")
.executeTakeFirst();
expect(row).toBeDefined();
expect(new Date(row!.last_score_time).getTime()).toBe(time.getTime());
});
it("advances the cursor to a later timestamp", async () => {
const t1 = new Date("2025-01-01T00:00:00.000Z");
const t2 = new Date("2025-06-01T00:00:00.000Z");
await SetImportTimestop(userID, "api/eag-iidx", t1);
await SetImportTimestop(userID, "api/eag-iidx", t2);
const result = await GetImportTimestop(userID, "api/eag-iidx");
expect(result!.getTime()).toBe(t2.getTime());
});
it("does not go backwards when called with an earlier timestamp", async () => {
const t1 = new Date("2025-06-01T00:00:00.000Z");
const t2 = new Date("2025-01-01T00:00:00.000Z");
await SetImportTimestop(userID, "api/eag-iidx", t1);
await SetImportTimestop(userID, "api/eag-iidx", t2);
const result = await GetImportTimestop(userID, "api/eag-iidx");
// GREATEST keeps t1
expect(result!.getTime()).toBe(t1.getTime());
});
it("only maintains one row per (user, import_type)", async () => {
const t1 = new Date("2025-01-01T00:00:00.000Z");
const t2 = new Date("2025-06-01T00:00:00.000Z");
await SetImportTimestop(userID, "api/eag-iidx", t1);
await SetImportTimestop(userID, "api/eag-iidx", t2);
const count = await DB.selectFrom("import_timestop")
.select(DB.fn.countAll().as("count"))
.where("import_timestop.user_id", "=", userID)
.where("import_timestop.import_type", "=", "api/eag-iidx")
.executeTakeFirstOrThrow();
expect(Number(count.count)).toBe(1);
});
it("does not affect other users", async () => {
const other = await seedUser({ username: "other_user" });
const myTime = new Date("2025-01-01T00:00:00.000Z");
const theirTime = new Date("2026-01-01T00:00:00.000Z");
await SetImportTimestop(userID, "api/eag-iidx", myTime);
await SetImportTimestop(other.id, "api/eag-iidx", theirTime);
const myResult = await GetImportTimestop(userID, "api/eag-iidx");
const theirResult = await GetImportTimestop(other.id, "api/eag-iidx");
expect(myResult!.getTime()).toBe(myTime.getTime());
expect(theirResult!.getTime()).toBe(theirTime.getTime());
});
});
describe("ListImportTimestops", () => {
let userID: number;
beforeEach(async () => {
({ id: userID } = await seedUser());
});
it("returns all API import types with null when unset", async () => {
const result = await ListImportTimestops(userID);
expect(result.length).toBeGreaterThan(0);
expect(result.every((entry) => entry.lastScoreTime === null)).toBe(true);
});
it("includes stored cursors", async () => {
const time = new Date("2025-03-15T12:00:00.000Z");
await SetImportTimestop(userID, "api/eag-iidx", time);
const result = await ListImportTimestops(userID);
const entry = result.find((e) => e.importType === "api/eag-iidx");
expect(entry?.lastScoreTime).toBe(time.getTime());
});
});
describe("DeleteImportTimestop", () => {
let userID: number;
beforeEach(async () => {
({ id: userID } = await seedUser());
});
it("removes an existing cursor", async () => {
await SetImportTimestop(userID, "api/eag-iidx", new Date("2025-01-01T00:00:00.000Z"));
await DeleteImportTimestop(userID, "api/eag-iidx");
expect(await GetImportTimestop(userID, "api/eag-iidx")).toBeNull();
});
});
describe("SetImportTimestopManual", () => {
let userID: number;
beforeEach(async () => {
({ id: userID } = await seedUser());
});
it("can move the cursor backwards", async () => {
const later = new Date("2025-06-01T00:00:00.000Z");
const earlier = new Date("2025-01-01T00:00:00.000Z");
await SetImportTimestop(userID, "api/eag-iidx", later);
await SetImportTimestopManual(userID, "api/eag-iidx", earlier);
const result = await GetImportTimestop(userID, "api/eag-iidx");
expect(result!.getTime()).toBe(earlier.getTime());
});
});
@@ -0,0 +1,113 @@
import type { APIImportTypes, ImportTypes, integer } from "tachi-common";
import DB from "#services/pg/db";
import { sql } from "kysely";
import { apiImportTypes } from "tachi-common/constants/import-types";
export type ImportTimestopEntry = {
importType: APIImportTypes;
lastScoreTime: number | null;
};
/**
* Returns the stored last-score-time cursor for a given user + import type,
* or null if no import has been completed yet.
*/
export async function GetImportTimestop(
userID: integer,
importType: ImportTypes,
): Promise<Date | null> {
const row = await DB.selectFrom("import_timestop")
.select(["import_timestop.last_score_time"])
.where("import_timestop.user_id", "=", userID)
.where("import_timestop.import_type", "=", importType)
.executeTakeFirst();
if (!row) {
return null;
}
return new Date(row.last_score_time);
}
/**
* Upserts the last-score-time cursor for a given user + import type, always
* advancing to the greater of the existing value and the new one.
*/
export async function SetImportTimestop(
userID: integer,
importType: ImportTypes,
time: Date,
): Promise<void> {
const isoTime = time.toISOString();
await DB.insertInto("import_timestop")
.values({
user_id: userID,
import_type: importType,
last_score_time: isoTime,
})
.onConflict((oc) =>
oc.columns(["user_id", "import_type"]).doUpdateSet({
last_score_time: sql<string>`GREATEST(EXCLUDED.last_score_time, import_timestop.last_score_time)`,
}),
)
.execute();
}
/**
* Returns every API import type and its stored cursor (null when unset).
*/
export async function ListImportTimestops(userID: integer): Promise<Array<ImportTimestopEntry>> {
const rows = await DB.selectFrom("import_timestop")
.select(["import_timestop.import_type", "import_timestop.last_score_time"])
.where("import_timestop.user_id", "=", userID)
.execute();
const byType = new Map(
rows.map((row) => [row.import_type, new Date(row.last_score_time).getTime()]),
);
return apiImportTypes.map((importType) => ({
importType,
lastScoreTime: byType.get(importType) ?? null,
}));
}
/**
* Removes the stored cursor so the next import starts from scratch.
*/
export async function DeleteImportTimestop(
userID: integer,
importType: ImportTypes,
): Promise<void> {
await DB.deleteFrom("import_timestop")
.where("import_timestop.user_id", "=", userID)
.where("import_timestop.import_type", "=", importType)
.execute();
}
/**
* Sets the cursor to an exact timestamp, replacing any existing value.
* Unlike {@link SetImportTimestop}, this can move the cursor backwards.
*/
export async function SetImportTimestopManual(
userID: integer,
importType: ImportTypes,
time: Date,
): Promise<void> {
const isoTime = time.toISOString();
await DB.insertInto("import_timestop")
.values({
user_id: userID,
import_type: importType,
last_score_time: isoTime,
})
.onConflict((oc) =>
oc.columns(["user_id", "import_type"]).doUpdateSet({
last_score_time: isoTime,
}),
)
.execute();
}
@@ -7,6 +7,7 @@ import type { ScoreImportJob } from "#lib/score-import/worker/types";
import { LoadImportDocumentById } from "#lib/db-formats/import-document";
import { clearPbDirtyForUser } from "#lib/jobs/drain-dirty-queues";
import { SetImportTimestop } from "#lib/score-import/framework/common/timestop";
import { runWithImportContext } from "#lib/score-import/framework/import-run-context";
import {
deleteImportRun,
@@ -19,6 +20,7 @@ import DB from "#services/pg/db";
import { GetMillisecondsSince } from "#utils/misc";
import { GetUserWithID } from "#utils/user";
import {
type APIImportTypes,
type GameGroup,
GetGameGroupConfig,
type GoalImportInfo,
@@ -29,6 +31,7 @@ import {
type UserDocument,
type V3Game,
} from "tachi-common";
import { apiImportTypes } from "tachi-common/constants/import-types";
import type { ClassProvider } from "../calculated-data/types";
import type { ChartIDGameMap, ScoreGameMap } from "../common/types";
@@ -238,6 +241,20 @@ export default async function ScoreImportMain<D, C>(
observeScoreImportDuration(importType, Date.now() - timeStarted);
// For API imports, advance the per-user timestop cursor so subsequent
// imports can stop once they reach already-imported scores.
if (apiImportTypes.includes(importType as APIImportTypes)) {
const maxTimeAchieved = importInfo
.filter((i): i is { success: true } & typeof i => i.success)
.map((i) => i.content.score.timeAchieved)
.filter((t): t is number => t !== null)
.reduce((max, t) => Math.max(max, t), -Infinity);
if (Number.isFinite(maxTimeAchieved)) {
await SetImportTimestop(userID, importType, new Date(maxTimeAchieved));
}
}
const loaded = await LoadImportDocumentById(importID);
if (!loaded) {
@@ -1,11 +1,15 @@
import type { KtLogger } from "#lib/log/log";
import type { integer } from "tachi-common";
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
import { ParseKaiIIDX } from "#lib/score-import/import-types/common/api-kai/iidx/parser";
import { GetKaiAuthGuaranteed } from "#utils/queries/auth";
export async function ParseEagIIDX(userID: integer, log: KtLogger) {
const authDoc = await GetKaiAuthGuaranteed(userID, "EAG", log);
const [authDoc, lastScoreTime] = await Promise.all([
GetKaiAuthGuaranteed(userID, "EAG", log),
GetImportTimestop(userID, "api/eag-iidx"),
]);
return ParseKaiIIDX("EAG", authDoc, log);
return ParseKaiIIDX("EAG", authDoc, log, undefined, null, lastScoreTime);
}
@@ -1,11 +1,15 @@
import type { KtLogger } from "#lib/log/log";
import type { integer } from "tachi-common";
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
import { ParseKaiSDVX } from "#lib/score-import/import-types/common/api-kai/sdvx/parser";
import { GetKaiAuthGuaranteed } from "#utils/queries/auth";
export async function ParseEagSDVX(userID: integer, log: KtLogger) {
const authDoc = await GetKaiAuthGuaranteed(userID, "EAG", log);
const [authDoc, lastScoreTime] = await Promise.all([
GetKaiAuthGuaranteed(userID, "EAG", log),
GetImportTimestop(userID, "api/eag-sdvx"),
]);
return ParseKaiSDVX("EAG", authDoc, log);
return ParseKaiSDVX("EAG", authDoc, log, undefined, null, lastScoreTime);
}
@@ -1,11 +1,15 @@
import type { KtLogger } from "#lib/log/log";
import type { integer } from "tachi-common";
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
import { ParseKaiIIDX } from "#lib/score-import/import-types/common/api-kai/iidx/parser";
import { GetKaiAuthGuaranteed } from "#utils/queries/auth";
export async function ParseFloIIDX(userID: integer, log: KtLogger) {
const authDoc = await GetKaiAuthGuaranteed(userID, "FLO", log);
const [authDoc, lastScoreTime] = await Promise.all([
GetKaiAuthGuaranteed(userID, "FLO", log),
GetImportTimestop(userID, "api/flo-iidx"),
]);
return ParseKaiIIDX("FLO", authDoc, log);
return ParseKaiIIDX("FLO", authDoc, log, undefined, null, lastScoreTime);
}
@@ -1,11 +1,15 @@
import type { KtLogger } from "#lib/log/log";
import type { integer } from "tachi-common";
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
import { ParseKaiSDVX } from "#lib/score-import/import-types/common/api-kai/sdvx/parser";
import { GetKaiAuthGuaranteed } from "#utils/queries/auth";
export async function ParseFloSDVX(userID: integer, log: KtLogger) {
const authDoc = await GetKaiAuthGuaranteed(userID, "FLO", log);
const [authDoc, lastScoreTime] = await Promise.all([
GetKaiAuthGuaranteed(userID, "FLO", log),
GetImportTimestop(userID, "api/flo-sdvx"),
]);
return ParseKaiSDVX("FLO", authDoc, log);
return ParseKaiSDVX("FLO", authDoc, log, undefined, null, lastScoreTime);
}
@@ -1,11 +1,15 @@
import type { KtLogger } from "#lib/log/log";
import type { integer } from "tachi-common";
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
import { ParseKaiSDVX } from "#lib/score-import/import-types/common/api-kai/sdvx/parser";
import { GetKaiAuthGuaranteed } from "#utils/queries/auth";
export async function ParseMinSDVX(userID: integer, log: KtLogger) {
const authDoc = await GetKaiAuthGuaranteed(userID, "MIN", log);
const [authDoc, lastScoreTime] = await Promise.all([
GetKaiAuthGuaranteed(userID, "MIN", log),
GetImportTimestop(userID, "api/min-sdvx"),
]);
return ParseKaiSDVX("MIN", authDoc, log);
return ParseKaiSDVX("MIN", authDoc, log, undefined, null, lastScoreTime);
}
@@ -3,6 +3,7 @@ import type { ParserFunctionReturns } from "#lib/score-import/import-types/commo
import type { EmptyObject } from "#utils/types";
import type { GamesForGroup, integer } from "tachi-common";
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
import { drainMytPlaylogStream } from "#lib/score-import/import-types/common/api-myt/buffer-playlog-stream";
import {
CreateMytTransport,
@@ -15,9 +16,16 @@ import { createClient } from "@connectrpc/connect";
import type { MytChunithmScore } from "./types";
async function* streamPlaylog(userID: integer, log: KtLogger): AsyncIterable<MytChunithmScore> {
const profileApiId = await FetchMytTitleAPIID(userID, "chunithm", log);
const [profileApiId, lastScoreTime] = await Promise.all([
FetchMytTitleAPIID(userID, "chunithm", log),
GetImportTimestop(userID, "api/myt-chunithm"),
]);
const client = createClient(ChunithmUser, CreateMytTransport());
const request = create(GetPlaylogRequestSchema, { profileApiId });
const request = create(GetPlaylogRequestSchema, {
profileApiId,
lastUserPlayDate: lastScoreTime?.toISOString() ?? undefined,
});
yield* await drainMytPlaylogStream(client.getPlaylog(request), log, {
gameLabel: "Chunithm",
@@ -3,6 +3,7 @@ import type { ParserFunctionReturns } from "#lib/score-import/import-types/commo
import type { EmptyObject } from "#utils/types";
import type { GamesForGroup, integer } from "tachi-common";
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
import { drainMytPlaylogStream } from "#lib/score-import/import-types/common/api-myt/buffer-playlog-stream";
import {
CreateMytTransport,
@@ -15,9 +16,16 @@ import { createClient } from "@connectrpc/connect";
import type { MytMaimaiDxScore } from "./types";
async function* streamPlaylog(userID: integer, log: KtLogger): AsyncIterable<MytMaimaiDxScore> {
const profileApiId = await FetchMytTitleAPIID(userID, "maimaidx", log);
const [profileApiId, lastScoreTime] = await Promise.all([
FetchMytTitleAPIID(userID, "maimaidx", log),
GetImportTimestop(userID, "api/myt-maimaidx"),
]);
const client = createClient(MaimaiUser, CreateMytTransport());
const request = create(GetPlaylogRequestSchema, { profileApiId });
const request = create(GetPlaylogRequestSchema, {
profileApiId,
lastUserPlayDate: lastScoreTime?.toISOString() ?? undefined,
});
yield* await drainMytPlaylogStream(client.getPlaylog(request), log, {
gameLabel: "maimai DX",
@@ -3,6 +3,7 @@ import type { ParserFunctionReturns } from "#lib/score-import/import-types/commo
import type { EmptyObject } from "#utils/types";
import type { GamesForGroup, integer } from "tachi-common";
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
import { drainMytPlaylogStream } from "#lib/score-import/import-types/common/api-myt/buffer-playlog-stream";
import {
CreateMytTransport,
@@ -15,9 +16,16 @@ import { createClient } from "@connectrpc/connect";
import type { MytOngekiScore } from "./types";
async function* streamPlaylog(userID: integer, log: KtLogger): AsyncIterable<MytOngekiScore> {
const profileApiId = await FetchMytTitleAPIID(userID, "ongeki", log);
const [profileApiId, lastScoreTime] = await Promise.all([
FetchMytTitleAPIID(userID, "ongeki", log),
GetImportTimestop(userID, "api/myt-ongeki"),
]);
const client = createClient(OngekiUser, CreateMytTransport());
const request = create(GetPlaylogRequestSchema, { profileApiId });
const request = create(GetPlaylogRequestSchema, {
profileApiId,
lastUserPlayDate: lastScoreTime?.toISOString() ?? undefined,
});
yield* await drainMytPlaylogStream(client.getPlaylog(request), log, {
gameLabel: "Ongeki",
@@ -3,6 +3,7 @@ import type { ParserFunctionReturns } from "#lib/score-import/import-types/commo
import type { EmptyObject } from "#utils/types";
import type { GamesForGroup, integer } from "tachi-common";
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error";
import { drainMytPlaylogStream } from "#lib/score-import/import-types/common/api-myt/buffer-playlog-stream";
import {
@@ -21,6 +22,7 @@ async function* streamPlaylog(
apiId: string,
log: KtLogger,
userID: integer,
lastScoreTime: Date | null,
): AsyncIterable<MytWaccaScore> {
const client = createClient(WaccaUser, CreateMytTransport());
const request = create(PlaylogRequestSchema, { apiId });
@@ -30,12 +32,22 @@ async function* streamPlaylog(
userID,
});
const cutoff = lastScoreTime?.getTime() ?? null;
for (const item of items) {
if (!item.info) {
log.warn(`Received WACCA playlog stream item with no info - skipping.`);
continue;
}
if (cutoff !== null) {
const parsed = Date.parse(item.info.userPlayDate);
if (!Number.isNaN(parsed) && parsed <= cutoff) {
continue;
}
}
yield item.info;
}
}
@@ -44,7 +56,10 @@ export default async function ParseMytWACCA(
userID: integer,
log: KtLogger,
): Promise<ParserFunctionReturns<MytWaccaScore, EmptyObject, GamesForGroup["wacca"]>> {
const titleApiId = await FetchMytTitleAPIID(userID, "wacca", log);
const [titleApiId, lastScoreTime] = await Promise.all([
FetchMytTitleAPIID(userID, "wacca", log),
GetImportTimestop(userID, "api/myt-wacca"),
]);
let classProvider;
@@ -57,7 +72,7 @@ export default async function ParseMytWACCA(
return {
service: "MYT",
iterable: streamPlaylog(titleApiId, log, userID),
iterable: streamPlaylog(titleApiId, log, userID, lastScoreTime),
context: {},
classProvider,
gameGroup: "wacca",
@@ -1,11 +1,12 @@
import type { KtLogger } from "#lib/log/log";
import { SELECT_CG_CARD_INFO, ToCGCardInfo } from "#lib/db-formats/cg-card-info";
import { GetImportTimestop } from "#lib/score-import/framework/common/timestop";
import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error";
import DB from "#services/pg/db";
import fetch from "node-fetch";
import { p, type PrudenceSchema } from "prudence";
import { FormatPrError, type integer, type V3Game } from "tachi-common";
import { type APIImportTypes, FormatPrError, type integer, type V3Game } from "tachi-common";
import type { ParserFunctionReturns } from "../types";
import type {
@@ -106,16 +107,23 @@ const CG_SCHEMAS: Record<CGSupportedGames, PrudenceSchema> = {
* Create a CG parser for this supported game. Since all CG parsing code is effectively
* identical, this basically just placeholders cgGame and service.
*/
export function CreateCGParser<T>(cgGame: CGSupportedGames, service: CGServices) {
export function CreateCGParser<T extends { dateTime: string }>(
cgGame: CGSupportedGames,
service: CGServices,
importType: APIImportTypes,
) {
return async (
userID: integer,
log: KtLogger,
): Promise<ParserFunctionReturns<T, CGContext, V3Game>> => {
const row = await DB.selectFrom("priv_svc_cg_card_info")
.select(SELECT_CG_CARD_INFO)
.where("user_id", "=", userID)
.where("service", "=", service)
.executeTakeFirst();
const [row, lastScoreTime] = await Promise.all([
DB.selectFrom("priv_svc_cg_card_info")
.select(SELECT_CG_CARD_INFO)
.where("priv_svc_cg_card_info.user_id", "=", userID)
.where("priv_svc_cg_card_info.service", "=", service)
.executeTakeFirst(),
GetImportTimestop(userID, importType),
]);
const cardInfo = row ? ToCGCardInfo(row) : undefined;
@@ -137,6 +145,17 @@ export function CreateCGParser<T>(cgGame: CGSupportedGames, service: CGServices)
throw new ScoreImportFatalError(400, FormatPrError(err, `Invalid CG ${cgGame} Score.`));
}
const cutoff = lastScoreTime?.getTime() ?? null;
const filtered =
cutoff === null
? (scores as Array<T>)
: (scores as Array<T>).filter((s) => {
const parsed = Date.parse(s.dateTime);
return Number.isNaN(parsed) || parsed > cutoff;
});
return {
service: FormatCGService(service),
context: {
@@ -144,23 +163,23 @@ export function CreateCGParser<T>(cgGame: CGSupportedGames, service: CGServices)
userID: cardInfo.userID,
},
gameGroup: CGGameToTachiGame(cgGame),
iterable: scores as Array<T>,
iterable: filtered,
classProvider: null,
};
};
}
export const ParseCGDevMuseca = CreateCGParser<CGMusecaScore>("msc", "dev");
export const ParseCGDevSDVX = CreateCGParser<CGSDVXScore>("sdvx", "dev");
export const ParseCGDevJubeat = CreateCGParser<CGJubeatScore>("jb", "dev");
export const ParseCGDevPopn = CreateCGParser<CGPopnScore>("popn", "dev");
export const ParseCGDevMuseca = CreateCGParser<CGMusecaScore>("msc", "dev", "api/cg-dev-museca");
export const ParseCGDevSDVX = CreateCGParser<CGSDVXScore>("sdvx", "dev", "api/cg-dev-sdvx");
export const ParseCGDevJubeat = CreateCGParser<CGJubeatScore>("jb", "dev", "api/cg-dev-jubeat");
export const ParseCGDevPopn = CreateCGParser<CGPopnScore>("popn", "dev", "api/cg-dev-popn");
export const ParseCGGanMuseca = CreateCGParser<CGMusecaScore>("msc", "gan");
export const ParseCGGanSDVX = CreateCGParser<CGSDVXScore>("sdvx", "gan");
export const ParseCGGanJubeat = CreateCGParser<CGJubeatScore>("jb", "gan");
export const ParseCGGanPopn = CreateCGParser<CGPopnScore>("popn", "gan");
export const ParseCGGanMuseca = CreateCGParser<CGMusecaScore>("msc", "gan", "api/cg-gan-museca");
export const ParseCGGanSDVX = CreateCGParser<CGSDVXScore>("sdvx", "gan", "api/cg-gan-sdvx");
export const ParseCGGanJubeat = CreateCGParser<CGJubeatScore>("jb", "gan", "api/cg-gan-jubeat");
export const ParseCGGanPopn = CreateCGParser<CGPopnScore>("popn", "gan", "api/cg-gan-popn");
export const ParseCGNagMuseca = CreateCGParser<CGMusecaScore>("msc", "nag");
export const ParseCGNagSDVX = CreateCGParser<CGSDVXScore>("sdvx", "nag");
export const ParseCGNagJubeat = CreateCGParser<CGJubeatScore>("jb", "nag");
export const ParseCGNagPopn = CreateCGParser<CGPopnScore>("popn", "nag");
export const ParseCGNagMuseca = CreateCGParser<CGMusecaScore>("msc", "nag", "api/cg-nag-museca");
export const ParseCGNagSDVX = CreateCGParser<CGSDVXScore>("sdvx", "nag", "api/cg-nag-sdvx");
export const ParseCGNagJubeat = CreateCGParser<CGJubeatScore>("jb", "nag", "api/cg-nag-jubeat");
export const ParseCGNagPopn = CreateCGParser<CGPopnScore>("popn", "nag", "api/cg-nag-popn");
@@ -6,6 +6,7 @@ import nodeFetch from "#utils/fetch";
import type { KaiContext } from "../types";
import { applyKaiTimestop } from "../kai-timestop";
import { CreateKaiReauthFunction } from "../reauth";
import { TraverseKaiAPI } from "../traverse-api";
import { KaiTypeToBaseURL } from "../utils";
@@ -17,6 +18,7 @@ export async function ParseKaiIIDX(
log: KtLogger,
fetch = nodeFetch,
reauthFn: (() => Promise<string>) | null = null,
lastScoreTime: Date | null = null,
): Promise<ParserFunctionReturns<unknown, KaiContext, GamesForGroup["iidx"]>> {
const baseUrl = KaiTypeToBaseURL(service);
@@ -27,13 +29,16 @@ export async function ParseKaiIIDX(
return {
service,
iterable: TraverseKaiAPI(
baseUrl,
"/api/iidx/v2/play_history",
authDoc.token,
log,
resolvedReauthFn,
fetch,
iterable: applyKaiTimestop(
TraverseKaiAPI(
baseUrl,
"/api/iidx/v2/play_history",
authDoc.token,
log,
resolvedReauthFn,
fetch,
),
lastScoreTime,
),
context: {
service,
@@ -0,0 +1,96 @@
import { agta } from "#test-utils/misc";
import { describe, expect, it } from "vitest";
import { applyKaiTimestop } from "./kai-timestop";
function source(items: unknown[]) {
return (async function* () {
for (const item of items) {
yield item;
}
})();
}
const T_OLD = "2024-01-01T00:00:00.000Z"; // 1704067200000
const T_NEW = "2025-01-01T00:00:00.000Z"; // 1735689600000
const CUTOFF = new Date("2024-06-01T00:00:00.000Z"); // between old and new
describe("applyKaiTimestop", () => {
it("yields all items when lastScoreTime is null", async () => {
const items = [{ timestamp: T_OLD }, { timestamp: T_NEW }, { other: "no timestamp" }];
const result = await agta(applyKaiTimestop(source(items), null));
expect(result).toStrictEqual(items);
});
it("yields items newer than the cutoff", async () => {
const items = [{ timestamp: T_NEW }, { timestamp: T_NEW }];
const result = await agta(applyKaiTimestop(source(items), CUTOFF));
expect(result).toStrictEqual(items);
});
it("stops at the first item at or before the cutoff", async () => {
const items = [
{ timestamp: T_NEW },
{ timestamp: T_OLD }, // <= cutoff → stop here
{ timestamp: T_NEW }, // would have been yielded but we already stopped
];
const result = await agta(applyKaiTimestop(source(items), CUTOFF));
// Only the first item (newer) should come through
expect(result).toStrictEqual([{ timestamp: T_NEW }]);
});
it("stops immediately when the very first item is at or before the cutoff", async () => {
const items = [{ timestamp: T_OLD }, { timestamp: T_NEW }];
const result = await agta(applyKaiTimestop(source(items), CUTOFF));
expect(result).toStrictEqual([]);
});
it("yields items with no timestamp field through without stopping", async () => {
const items = [{ other: "field" }, { timestamp: T_NEW }];
const result = await agta(applyKaiTimestop(source(items), CUTOFF));
expect(result).toStrictEqual(items);
});
it("yields items with a non-string timestamp field through without stopping", async () => {
const items = [{ timestamp: 12345 }, { timestamp: T_NEW }];
const result = await agta(applyKaiTimestop(source(items), CUTOFF));
expect(result).toStrictEqual(items);
});
it("yields items with an unparseable timestamp through without stopping", async () => {
const items = [{ timestamp: "not-a-date" }, { timestamp: T_NEW }];
const result = await agta(applyKaiTimestop(source(items), CUTOFF));
expect(result).toStrictEqual(items);
});
it("stops on an item whose timestamp exactly equals the cutoff", async () => {
const items = [
{ timestamp: T_NEW },
{ timestamp: CUTOFF.toISOString() }, // exactly at cutoff
];
const result = await agta(applyKaiTimestop(source(items), CUTOFF));
expect(result).toStrictEqual([{ timestamp: T_NEW }]);
});
it("yields an empty source unchanged", async () => {
const result = await agta(applyKaiTimestop(source([]), CUTOFF));
expect(result).toStrictEqual([]);
});
});
@@ -0,0 +1,33 @@
/**
* Wraps a Kai async iterable and stops yielding once a score's timestamp is
* at or before the given lastScoreTime cursor. Kai play_history APIs return
* scores newest-first, so the first item whose timestamp is <= lastScoreTime
* marks the boundary of already-imported data.
*
* If lastScoreTime is null the source iterable is yielded through unchanged.
*/
export async function* applyKaiTimestop(
source: AsyncIterable<unknown>,
lastScoreTime: Date | null,
): AsyncIterable<unknown> {
if (!lastScoreTime) {
yield* source;
return;
}
const cutoff = lastScoreTime.getTime();
for await (const item of source) {
const ts = (item as Record<string, unknown>).timestamp;
if (typeof ts === "string") {
const parsed = Date.parse(ts);
if (!Number.isNaN(parsed) && parsed <= cutoff) {
return;
}
}
yield item;
}
}
@@ -6,6 +6,7 @@ import nodeFetch from "#utils/fetch";
import type { KaiContext } from "../types";
import { applyKaiTimestop } from "../kai-timestop";
import { CreateKaiReauthFunction } from "../reauth";
import { type KaiAPIReauthFunction, TraverseKaiAPI } from "../traverse-api";
import { KaiTypeToBaseURL } from "../utils";
@@ -17,6 +18,7 @@ export async function ParseKaiSDVX(
log: KtLogger,
fetch = nodeFetch,
reauthFn: KaiAPIReauthFunction | null = null,
lastScoreTime: Date | null = null,
): Promise<ParserFunctionReturns<unknown, KaiContext, GamesForGroup["sdvx"]>> {
const baseUrl = KaiTypeToBaseURL(service);
@@ -27,13 +29,16 @@ export async function ParseKaiSDVX(
return {
service,
iterable: TraverseKaiAPI(
baseUrl,
"/api/sdvx/v1/play_history",
authDoc.token,
log,
resolvedReauthFn,
fetch,
iterable: applyKaiTimestop(
TraverseKaiAPI(
baseUrl,
"/api/sdvx/v1/play_history",
authDoc.token,
log,
resolvedReauthFn,
fetch,
),
lastScoreTime,
),
context: {
service,
@@ -41,6 +41,7 @@ import "./users/_userID/following/router";
import "./users/_userID/notifications/router";
import "./users/_userID/sessions/router";
import "./users/_userID/imports/router";
import "./users/_userID/import-timestops/router";
import "./users/_userID/settings/router";
import "./users/_userID/integrations/router";
import "./users/_userID/integrations/cg/_cgType/router";
@@ -543,6 +543,34 @@ export const API_V1_SPEC = {
output: docArray<ImportTrackerDocument>(),
},
"GET /users/:userID/import-timestops": {
description: "List API import timestop cursors for the authenticated user.",
input: z.object({}),
output: z.strictObject({
timestops: z.array(
z.strictObject({
importType: z.string(),
lastScoreTime: z.number().nullable(),
}),
),
}),
},
"DELETE /users/:userID/import-timestops": {
description: "Reset an API import timestop cursor.",
input: z.object({ importType: z.string() }),
output: empty,
},
"PUT /users/:userID/import-timestops": {
description: "Set an API import timestop cursor to a specific timestamp.",
input: z.object({
importType: z.string(),
lastScoreTime: z.number(),
}),
output: empty,
},
// ────────────────────────────────────────────────
// Users /:userID/integrations/myt (Kamaitachi only)
// ────────────────────────────────────────────────
@@ -0,0 +1,121 @@
import { SetImportTimestop } from "#lib/score-import/framework/common/timestop";
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
import { seedUser } from "#test-utils/pg-fixtures";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
afterAll(() => CloseServerConnection());
async function loginAs(username: string, password = "password123") {
const res = await mockApi.post("/api/v1/auth/login").send({
username,
"!password": password,
captcha: "test",
});
return res.headers["set-cookie"] as unknown as string[];
}
describe("GET /api/v1/users/:userID/import-timestops", () => {
let cookie: string[];
beforeEach(async () => {
await seedUser({ username: "timestop_user", withCredential: true, withSettings: true });
await seedUser({
username: "other_user",
email: "other@example.com",
withCredential: true,
withSettings: true,
});
cookie = await loginAs("timestop_user");
});
it("returns 401 when not authenticated", async () => {
const res = await mockApi.get("/api/v1/users/1/import-timestops");
expect(res.status).toBe(401);
});
it("returns 403 when viewing another user's timestops", async () => {
const res = await mockApi.get("/api/v1/users/2/import-timestops").set("Cookie", cookie);
expect(res.status).toBe(403);
});
it("returns all API import types for the authenticated user", async () => {
await SetImportTimestop(1, "api/eag-iidx", new Date("2025-03-15T12:00:00.000Z"));
const res = await mockApi.get("/api/v1/users/1/import-timestops").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.body.timestops.length).toBeGreaterThan(0);
const entry = res.body.body.timestops.find(
(e: { importType: string }) => e.importType === "api/eag-iidx",
);
expect(entry.lastScoreTime).toBe(new Date("2025-03-15T12:00:00.000Z").getTime());
});
});
describe("DELETE /api/v1/users/:userID/import-timestops", () => {
let cookie: string[];
beforeEach(async () => {
await seedUser({ username: "timestop_user", withCredential: true, withSettings: true });
await SetImportTimestop(1, "api/eag-iidx", new Date("2025-03-15T12:00:00.000Z"));
cookie = await loginAs("timestop_user");
});
it("resets a timestop", async () => {
const res = await mockApi
.delete("/api/v1/users/1/import-timestops")
.set("Cookie", cookie)
.send({ importType: "api/eag-iidx" });
expect(res.status).toBe(200);
const list = await mockApi.get("/api/v1/users/1/import-timestops").set("Cookie", cookie);
const entry = list.body.body.timestops.find(
(e: { importType: string }) => e.importType === "api/eag-iidx",
);
expect(entry.lastScoreTime).toBeNull();
});
it("rejects invalid import types", async () => {
const res = await mockApi
.delete("/api/v1/users/1/import-timestops")
.set("Cookie", cookie)
.send({ importType: "file/batch-manual" });
expect(res.status).toBe(400);
});
});
describe("PUT /api/v1/users/:userID/import-timestops", () => {
let cookie: string[];
beforeEach(async () => {
await seedUser({ username: "timestop_user", withCredential: true, withSettings: true });
cookie = await loginAs("timestop_user");
});
it("sets a timestop to a specific timestamp", async () => {
const lastScoreTime = new Date("2024-06-01T08:30:00.000Z").getTime();
const res = await mockApi
.put("/api/v1/users/1/import-timestops")
.set("Cookie", cookie)
.send({ importType: "api/myt-ongeki", lastScoreTime });
expect(res.status).toBe(200);
const list = await mockApi.get("/api/v1/users/1/import-timestops").set("Cookie", cookie);
const entry = list.body.body.timestops.find(
(e: { importType: string }) => e.importType === "api/myt-ongeki",
);
expect(entry.lastScoreTime).toBe(lastScoreTime);
});
});
@@ -0,0 +1,84 @@
import { withRequestedUser, withSelf } from "#lib/router/middleware";
import { success } from "#lib/router/typed-router";
import {
DeleteImportTimestop,
ListImportTimestops,
SetImportTimestopManual,
} from "#lib/score-import/framework/common/timestop";
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
import { ExpectedErr } from "bliss";
import { type APIImportTypes } from "tachi-common";
import { apiImportTypes } from "tachi-common/constants/import-types";
function assertApiImportType(importType: string): APIImportTypes {
if (!apiImportTypes.includes(importType as APIImportTypes)) {
throw new ExpectedErr(400, `Invalid import type: ${importType}`);
}
return importType as APIImportTypes;
}
/**
* List API import timestop cursors for this user.
*
* @name GET /api/v1/users/:userID/import-timestops
*/
API_V1_ROUTER.add(
"GET /users/:userID/import-timestops",
withRequestedUser,
withSelf,
async ({ ctx }) => {
const { requestedUser: user } = ctx;
const timestops = await ListImportTimestops(user.id);
return success(`Returned ${timestops.length} import timestops.`, { timestops });
},
);
/**
* Reset an API import timestop cursor so the next import starts from scratch.
*
* @param importType - The API import type to reset.
*
* @name DELETE /api/v1/users/:userID/import-timestops
*/
API_V1_ROUTER.add(
"DELETE /users/:userID/import-timestops",
withRequestedUser,
withSelf,
async ({ input, ctx }) => {
const { requestedUser: user } = ctx;
const importType = assertApiImportType(input.importType);
await DeleteImportTimestop(user.id, importType);
return success(`Reset import timestop for ${importType}.`, {});
},
);
/**
* Set an API import timestop cursor to a specific timestamp.
*
* @param importType - The API import type to update.
* @param lastScoreTime - Epoch milliseconds for the new cursor.
*
* @name PUT /api/v1/users/:userID/import-timestops
*/
API_V1_ROUTER.add(
"PUT /users/:userID/import-timestops",
withRequestedUser,
withSelf,
async ({ input, ctx }) => {
const { requestedUser: user } = ctx;
const importType = assertApiImportType(input.importType);
if (!Number.isFinite(input.lastScoreTime)) {
throw new ExpectedErr(400, "lastScoreTime must be a finite number.");
}
await SetImportTimestopManual(user.id, importType, new Date(input.lastScoreTime));
return success(`Updated import timestop for ${importType}.`, {});
},
);