-
{goalSub.progressHuman}
+ {onEdit && (
+
+
+
+ )}
+
+ {!isAchieved && (
+
+
+ {goalSub.progressHuman}
+
/ {goalSub.outOfHuman}
)}
@@ -282,8 +399,9 @@ export function InnerQuestSectionGoal({
function FormatGoalDependencies({ deps, isStandalone }: { deps: string[]; isStandalone: boolean }) {
let str;
+
if (isStandalone && deps.length === 0) {
- str = `You set this goal directly.`;
+ str = "You set this goal directly.";
} else if (isStandalone && deps.length > 0) {
str = `You set this goal, but it's also in ${HumanisedJoinArray(deps, "and")}.`;
} else if (deps.length === 0) {
diff --git a/typescript/client/src/components/targets/quests/editor/EditableQuest.tsx b/typescript/client/src/components/targets/quests/editor/EditableQuest.tsx
index 0dca224ea..b1f19f3bd 100644
--- a/typescript/client/src/components/targets/quests/editor/EditableQuest.tsx
+++ b/typescript/client/src/components/targets/quests/editor/EditableQuest.tsx
@@ -1,6 +1,6 @@
import QuickTooltip from "#components/layout/misc/QuickTooltip";
import Card from "#components/layout/page/Card";
-import AddNewGoalForQuestModal from "#components/targets/AddNewGoalForQuestModal";
+import GoalBuilder from "#components/targets/GoalBuilder";
import Divider from "#components/util/Divider";
import EditableText from "#components/util/EditableText";
import Icon from "#components/util/Icon";
@@ -9,7 +9,7 @@ import { type GamePT } from "#types/react";
import { type RawQuestDocument, type RawQuestGoal, type RawQuestSection } from "#types/tachi";
import { ChangeAtPosition, CopyToClipboard, DeleteInPosition } from "#util/misc";
import React, { useState } from "react";
-import { Button } from "react-bootstrap";
+import { Button, Collapse, Modal } from "react-bootstrap";
import { FormatGame } from "tachi-common";
export default function EditableQuest({
@@ -26,35 +26,23 @@ export default function EditableQuest({
header={
- onChange({
- ...quest,
- name,
- })
- }
+ onSubmit={(name) => onChange({ ...quest, name })}
placeholderText={quest.name || "Untitled Quest"}
/>
-
- onChange({
- ...quest,
- desc,
- })
- }
+ onSubmit={(desc) => onChange({ ...quest, desc })}
placeholderText={quest.desc || "Please set a description."}
/>
-
- Game: {FormatGame(quest.game)}
+ {FormatGame(quest.game)}
}
>
- {quest.rawQuestData.map((e, i) => (
+ {quest.rawQuestData.map((section, i) => (
- onChange({
- ...quest,
- rawQuestData: DeleteInPosition(quest.rawQuestData, i),
- })
+ onChange({ ...quest, rawQuestData: DeleteInPosition(quest.rawQuestData, i) })
}
- section={e}
+ onMoveDown={
+ i < quest.rawQuestData.length - 1
+ ? () => {
+ const arr = [...quest.rawQuestData];
+ [arr[i], arr[i + 1]] = [arr[i + 1]!, arr[i]!];
+ onChange({ ...quest, rawQuestData: arr });
+ }
+ : undefined
+ }
+ onMoveUp={
+ i > 0
+ ? () => {
+ const arr = [...quest.rawQuestData];
+ [arr[i - 1], arr[i]] = [arr[i]!, arr[i - 1]!];
+ onChange({ ...quest, rawQuestData: arr });
+ }
+ : undefined
+ }
+ section={section}
/>
))}
+
@@ -82,38 +86,31 @@ export default function EditableQuest({
...quest,
rawQuestData: [
...quest.rawQuestData,
- {
- title: "Untitled Section",
- desc: "",
- rawGoals: [],
- },
+ { title: "New Section", desc: "", rawGoals: [] },
],
})
}
variant="outline-success"
>
-
- Add New Quest Section
+ Add Section
+
-
-
-
- {
- CopyToClipboard(FormatQuest(quest));
- }}
- variant="outline-info"
- >
- Copy To Clipboard
-
-
-
+
+
+
+ CopyToClipboard(FormatQuestAsText(quest))}
+ variant="outline-info"
+ >
+ Copy Summary
+
+
{
- if (confirm("Are you absolutely sure you want to delete this quest?")) {
+ if (confirm("Delete this quest?")) {
onDelete();
}
}}
@@ -132,158 +129,229 @@ function QuestSection({
game,
onChange,
onDelete,
+ onMoveUp,
+ onMoveDown,
}: {
onChange: (newSection: RawQuestSection) => void;
onDelete: () => void;
+ onMoveDown?: () => void;
+ onMoveUp?: () => void;
section: RawQuestSection;
} & GamePT) {
- const [show, setShow] = useState(false);
+ const [showGoalBuilder, setShowGoalBuilder] = useState(false);
return (
- <>
-
-
- onChange({
- ...section,
- title,
- })
- }
- placeholderText="Untitled Section"
- />
-
-
- onChange({
- ...section,
- desc,
- })
- }
- placeholderText="No Description..."
- />
+
+
+
+ onChange({ ...section, title })}
+ placeholderText="Section Title"
+ />
+ onChange({ ...section, desc })}
+ placeholderText="Optional section description…"
+ />
+
+ {/* Reorder buttons */}
+
+
+
+
+
+
+
+
-
+
{section.rawGoals.length === 0 ? (
-
No Goals...
+
No goals yet.
) : (
-
- {section.rawGoals.map((e, i) => (
-
+ {section.rawGoals.map((rawGoal, i) => (
+
- onChange({
- ...section,
- rawGoals: ChangeAtPosition(section.rawGoals, newRawGoal, i),
- })
- }
- onInnerGoalDelete={() => {
+ onDelete={() =>
onChange({
...section,
rawGoals: DeleteInPosition(section.rawGoals, i),
- });
- }}
- rawGoal={e}
+ })
+ }
+ onMoveDown={
+ i < section.rawGoals.length - 1
+ ? () => {
+ const arr = [...section.rawGoals];
+ [arr[i], arr[i + 1]] = [arr[i + 1]!, arr[i]!];
+ onChange({ ...section, rawGoals: arr });
+ }
+ : undefined
+ }
+ onMoveUp={
+ i > 0
+ ? () => {
+ const arr = [...section.rawGoals];
+ [arr[i - 1], arr[i]] = [arr[i]!, arr[i - 1]!];
+ onChange({ ...section, rawGoals: arr });
+ }
+ : undefined
+ }
+ onUpdate={(newGoal) =>
+ onChange({
+ ...section,
+ rawGoals: ChangeAtPosition(section.rawGoals, newGoal, i),
+ })
+ }
+ rawGoal={rawGoal}
/>
))}
-
+
)}
-
-
-
setShow(true)} variant="outline-success">
+
+
+ setShowGoalBuilder(true)} size="sm" variant="outline-success">
Add Goal
{
- if (confirm("Are you absolutely sure you want to delete this section?")) {
+ if (confirm("Delete this section?")) {
onDelete();
}
}}
+ size="sm"
variant="outline-danger"
>
Delete Section
- {show && (
- {
- onChange({
- ...section,
- rawGoals: [...section.rawGoals, rawGoal],
- });
- }}
- setShow={setShow}
- show={show}
- />
- )}
- >
+
+ {/* GoalBuilder modal */}
+ setShowGoalBuilder(false)} show={showGoalBuilder} size="xl">
+
+ Add Goal to "{section.title}"
+
+
+ {
+ onChange({ ...section, rawGoals: [...section.rawGoals, rawGoal] });
+ setShowGoalBuilder(false);
+ }}
+ showNote
+ />
+
+
+
);
}
-function InnerQuestSectionGoal({
+function EditableGoalRow({
rawGoal,
game,
- onInnerGoalChange,
- onInnerGoalDelete,
+ onUpdate,
+ onDelete,
+ onMoveUp,
+ onMoveDown,
}: {
- onInnerGoalChange: (newRawGoal: RawQuestGoal) => void;
- onInnerGoalDelete: () => void;
+ onDelete: () => void;
+ onMoveDown?: () => void;
+ onMoveUp?: () => void;
+ onUpdate: (newGoal: RawQuestGoal) => void;
rawGoal: RawQuestGoal;
} & GamePT) {
- const [show, setShow] = useState(false);
+ const [showEdit, setShowEdit] = useState(false);
return (
-
-
-
{rawGoal.goal.name}
+
+
+
{rawGoal.goal.name}
+ {rawGoal.note &&
{rawGoal.note} }
-
-
- setShow(true)} type="pencil-alt" />
-
-
- {
- if (
- confirm(
- `Are you sure you want to remove the goal "${rawGoal.goal.name}"?`,
- )
- ) {
- onInnerGoalDelete();
- }
- }}
- type="trash"
- />
-
-
-
- {rawGoal.note &&
{rawGoal.note} }
- {show && (
-
{
- onInnerGoalChange(newRawGoal);
+
+
+
+
+
+
+
+ setShowEdit(true)}
+ title="Edit goal"
+ type="button"
+ >
+
+
+ {
+ if (confirm(`Remove goal "${rawGoal.goal.name}"?`)) {
+ onDelete();
+ }
}}
- setShow={setShow}
- show={show}
- />
+ title="Remove goal"
+ type="button"
+ >
+
+
+
+
+ {showEdit && (
+ setShowEdit(false)} show size="xl">
+
+ Edit Goal
+
+
+ {
+ onUpdate(updated);
+ setShowEdit(false);
+ }}
+ showNote
+ />
+
+
)}
-
+
);
}
-function FormatQuest(quest: RawQuestDocument) {
- let str = `# QUEST: ${quest.name}
-${quest.desc}
-(Game: ${FormatGame(quest.game)})`;
+function FormatQuestAsText(quest: RawQuestDocument): string {
+ let str = `# ${quest.name}\n${quest.desc}\n(${FormatGame(quest.game)})`;
for (const section of quest.rawQuestData) {
str += `\n\n### ${section.title}`;
@@ -295,10 +363,10 @@ ${quest.desc}
str += "\n";
for (const goal of section.rawGoals) {
- str += `\n-- ${goal.goal.name}`;
+ str += `\n- ${goal.goal.name}`;
if (goal.note) {
- str += `\n${goal.note}`;
+ str += `\n ${goal.note}`;
}
}
}
diff --git a/typescript/client/src/types/tachi.ts b/typescript/client/src/types/tachi.ts
index 04e9097ee..ad3fc0434 100644
--- a/typescript/client/src/types/tachi.ts
+++ b/typescript/client/src/types/tachi.ts
@@ -71,3 +71,18 @@ export type RawQuestGoal = {
goal: Pick;
note?: string;
};
+
+/**
+ * A questline document without a server-assigned `questlineID`. Used when
+ * authoring questlines locally in the Quest Editor before uploading as seeds.
+ */
+export type RawQuestlineDocument = {
+ /** User-chosen slug / identifier (becomes `questlineID` in seeds). */
+ questlineID: string;
+ name: string;
+ desc: string;
+ game: string;
+ playtype: string;
+ /** Ordered list of `RawQuestDocument` references by their local index or slug. */
+ quests: Array;
+};
diff --git a/typescript/seeds-scripts/rerunners/v3/7-remap-goals-folder-and-chart-ids.ts b/typescript/seeds-scripts/rerunners/v3/7-remap-goals-folder-and-chart-ids.ts
new file mode 100644
index 000000000..e8418692b
--- /dev/null
+++ b/typescript/seeds-scripts/rerunners/v3/7-remap-goals-folder-and-chart-ids.ts
@@ -0,0 +1,291 @@
+/**
+ * After charts + folders gained Postgres-style ids (`id` plus `legacyChartID` /
+ * `legacyFolderID`), `goals.json` may still reference legacy chart hashes and legacy
+ * folder ids inside `charts.data`. Remap those references, recalculate `goalID` hashes
+ * (same `{ charts, criteria, game }` convention as ../../util CreateGoalID), rewrite
+ * `quests.json` goal refs when ids change, and write `goal-id-remap.json` so
+ * mongo→postgres can translate subscriptions.
+ */
+
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+import { spawnSync } from "child_process";
+
+import { CreateGoalID } from "../../util";
+
+type GoalCharts =
+ | { data: Array; type: "multi" }
+ | { data: string; folderSlug?: string; type: "folder" }
+ | { data: string; type: "single" };
+
+interface GoalSeed {
+ charts: GoalCharts;
+ criteria:
+ | {
+ countNum: number;
+ key: string;
+ mode: "absolute" | "proportion";
+ value: number;
+ }
+ | {
+ key: string;
+ mode: "single";
+ value: number;
+ };
+ game: string;
+ goalID: string;
+ name: string;
+ playtype: string;
+}
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const SEEDS_DIR = path.join(__dirname, "../../../../db/seeds");
+
+const LEGACY_CHART_HEX_RE = /^[0-9a-f]{40}$/u;
+
+function readJsonArray(filePath: string): Array {
+ return JSON.parse(fs.readFileSync(filePath, "utf8")) as Array;
+}
+
+function buildChartLegacyToIdMaps(): {
+ canonicalChartIds: Set;
+ legacyToSeedId: Map;
+} {
+ const legacyToSeedId = new Map();
+ const canonicalChartIds = new Set();
+
+ for (const name of fs.readdirSync(SEEDS_DIR)) {
+ if (!/^charts-/u.test(name) || !name.endsWith(".json")) {
+ continue;
+ }
+
+ const charts = readJsonArray<{ id: string; legacyChartID?: string }>(
+ path.join(SEEDS_DIR, name),
+ );
+
+ for (const c of charts) {
+ canonicalChartIds.add(c.id);
+ const leg = c.legacyChartID;
+
+ if (leg) {
+ legacyToSeedId.set(leg.toLowerCase(), c.id);
+ }
+ }
+ }
+
+ return { canonicalChartIds, legacyToSeedId };
+}
+
+function buildFolderLegacyToIdMaps(): {
+ canonicalFolderIds: Set;
+ legacyToSeedId: Map;
+} {
+ const legacyToSeedId = new Map();
+ const canonicalFolderIds = new Set();
+ const folders = readJsonArray<{
+ id: string;
+ legacyFolderID: string;
+ }>(path.join(SEEDS_DIR, "folders.json"));
+
+ for (const f of folders) {
+ canonicalFolderIds.add(f.id);
+
+ const leg = f.legacyFolderID;
+
+ if (leg !== undefined && leg !== "") {
+ legacyToSeedId.set(leg.trim(), f.id);
+ }
+ }
+
+ return { canonicalFolderIds, legacyToSeedId };
+}
+
+function resolveChartGoalRef(
+ candidate: string,
+ legacyToSeedId: Map,
+ canonicalChartIds: Set,
+ context: string,
+ legacyHexRe: RegExp,
+): string {
+ const t = candidate.trim();
+ const lowered = t.toLowerCase();
+
+ if (canonicalChartIds.has(t)) {
+ return t;
+ }
+
+ const migrated = legacyToSeedId.get(lowered);
+
+ if (migrated !== undefined) {
+ return migrated;
+ }
+
+ if (!legacyHexRe.test(t)) {
+ console.warn(
+ `${context}: chart ref ${JSON.stringify(t)} not in canonical set and not a known legacy SHA — leaving as-is`,
+ );
+
+ return t;
+ }
+
+ throw new Error(`${context}: unknown legacy chart SHA ${JSON.stringify(t)}`);
+}
+
+function resolveFolderGoalRef(
+ candidate: string,
+ legacyToSeedId: Map,
+ canonicalFolderIds: Set,
+ context: string,
+): string {
+ const t = candidate.trim();
+
+ if (canonicalFolderIds.has(t)) {
+ return t;
+ }
+
+ const migrated = legacyToSeedId.get(t);
+
+ if (migrated !== undefined) {
+ return migrated;
+ }
+
+ throw new Error(`${context}: unknown folder reference ${JSON.stringify(t)}`);
+}
+
+function remapChartsBlock(
+ charts: GoalCharts,
+ context: string,
+ legacyHexRe: RegExp,
+ legacyToSeedChart: Map,
+ canonicalChartIds: Set,
+ legacyToSeedFolder: Map,
+ canonicalFolderIds: Set,
+): GoalCharts {
+ switch (charts.type) {
+ case "single":
+ return {
+ ...charts,
+ data: resolveChartGoalRef(
+ charts.data,
+ legacyToSeedChart,
+ canonicalChartIds,
+ context,
+ legacyHexRe,
+ ),
+ };
+ case "multi":
+ return {
+ ...charts,
+ data: charts.data.map((cid, i) =>
+ resolveChartGoalRef(
+ cid,
+ legacyToSeedChart,
+ canonicalChartIds,
+ `${context} [multi ${i}]`,
+ legacyHexRe,
+ ),
+ ),
+ };
+ case "folder":
+ return {
+ ...charts,
+ data: resolveFolderGoalRef(charts.data, legacyToSeedFolder, canonicalFolderIds, context),
+ };
+ }
+}
+
+const translateMap = new Map();
+
+const goalsPath = path.join(SEEDS_DIR, "goals.json");
+const goals = readJsonArray(goalsPath);
+
+const { canonicalChartIds, legacyToSeedId: chartLegacy } = buildChartLegacyToIdMaps();
+const { canonicalFolderIds, legacyToSeedId: folderLegacy } = buildFolderLegacyToIdMaps();
+
+for (const goal of goals) {
+ const oldId = goal.goalID;
+
+ goal.charts = remapChartsBlock(
+ goal.charts,
+ `goal ${JSON.stringify(goal.name)} (${oldId})`,
+ LEGACY_CHART_HEX_RE,
+ chartLegacy,
+ canonicalChartIds,
+ folderLegacy,
+ canonicalFolderIds,
+ );
+
+ const newId = CreateGoalID(goal.charts, goal.criteria, goal.game);
+
+ goal.goalID = newId;
+
+ if (newId !== oldId) {
+ translateMap.set(oldId, newId);
+ }
+}
+
+console.log(`goals.json: remapped chart/folder refs; ${translateMap.size} goal IDs will change`);
+
+fs.writeFileSync(goalsPath, `${JSON.stringify(goals, null, "\t")}\n`);
+
+type QuestSeed = {
+ desc: string;
+ game: string;
+ name: string;
+ playtype: string;
+ questData: Array<{
+ desc?: string;
+ goals: Array<{ goalID: string; note?: string }>;
+ title: string;
+ }>;
+ questID: string;
+};
+
+const questsPath = path.join(SEEDS_DIR, "quests.json");
+const quests = readJsonArray(questsPath);
+
+let questsUpdated = 0;
+
+for (const quest of quests) {
+ let touched = false;
+
+ for (const qd of quest.questData) {
+ for (let i = 0; i < qd.goals.length; i++) {
+ const gRef = qd.goals[i];
+ const nextId = translateMap.get(gRef.goalID);
+
+ if (nextId !== undefined && nextId !== gRef.goalID) {
+ qd.goals[i] = { ...gRef, goalID: nextId };
+ touched = true;
+ }
+ }
+ }
+
+ if (touched) {
+ questsUpdated++;
+ }
+}
+
+fs.writeFileSync(questsPath, `${JSON.stringify(quests, null, "\t")}\n`);
+console.log(`quests.json: updated goal refs on ${questsUpdated} quests`);
+
+const remapPath = path.join(SEEDS_DIR, "goal-id-remap.json");
+fs.writeFileSync(remapPath, `${JSON.stringify(Object.fromEntries(translateMap), null, "\t")}\n`);
+
+console.log(`goal-id-remap.json: wrote ${translateMap.size} translations`);
+
+const repoRoot = path.resolve(SEEDS_DIR, "..", "..");
+const biome = path.join(repoRoot, "node_modules", ".bin", "biome");
+const biomeResult = spawnSync(
+ biome,
+ ["format", "--write", goalsPath, questsPath, remapPath],
+ {
+ cwd: repoRoot,
+ stdio: "inherit",
+ },
+);
+
+if (biomeResult.status !== 0) {
+ throw new Error(`biome format exited ${biomeResult.status}`);
+}
diff --git a/typescript/seeds-scripts/sort-seeds.js b/typescript/seeds-scripts/sort-seeds.js
index 7feca09ea..11e685f83 100644
--- a/typescript/seeds-scripts/sort-seeds.js
+++ b/typescript/seeds-scripts/sort-seeds.js
@@ -77,6 +77,11 @@ function SortSeeds() {
const collPath = path.join(collectionsDir, collection);
let content = JSON.parse(fs.readFileSync(collPath));
+ // Auxiliary maps (non-array roots) coexist with seed collections — skip them.
+ if (!Array.isArray(content)) {
+ continue;
+ }
+
if (collection.startsWith("charts-")) {
content.sort(ChartSort);
} else if (collection.startsWith("songs-")) {
diff --git a/typescript/seeds-scripts/test/collections.test.ts b/typescript/seeds-scripts/test/collections.test.ts
index 743a123d8..afd9c19f0 100644
--- a/typescript/seeds-scripts/test/collections.test.ts
+++ b/typescript/seeds-scripts/test/collections.test.ts
@@ -20,9 +20,12 @@ function FormatPrError(err, foreword = "Error") {
let exitCode = 0;
const suites: Array<{ good: boolean; name: string; report: unknown }> = [];
+/** Maps / metadata — not seed document arrays validated by {@link V3_SCHEMAS}. */
+const NON_SEED_JSON = new Set(["goal-id-remap.json"]);
+
const collections = fs
.readdirSync(path.join(__dirname, "../../../db/seeds"))
- .filter((e) => e.endsWith(".json"))
+ .filter((e) => e.endsWith(".json") && !NON_SEED_JSON.has(path.basename(e)))
.map((e) => path.basename(e)) as Array;
for (const collection of collections) {
diff --git a/typescript/server/src/actions/update-goal-subscription.test.ts b/typescript/server/src/actions/update-goal-subscription.test.ts
new file mode 100644
index 000000000..4be0f1101
--- /dev/null
+++ b/typescript/server/src/actions/update-goal-subscription.test.ts
@@ -0,0 +1,175 @@
+import DB from "#services/pg/db";
+import { seedMinimalIidxSpChart, seedUser } from "#test-utils/pg-fixtures";
+import { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } from "#utils/db";
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { ACTION_AddGoal } from "./add-goal";
+import { ACTION_UpdateGoalSubscription } from "./update-goal-subscription";
+
+describe("ACTION_UpdateGoalSubscription", () => {
+ let userID: number;
+ let username: string;
+ let chartId: string;
+ let taker: { ip: string; acct: { id: number; username: string } };
+
+ const baseInput = {
+ game: "iidx-sp" as const,
+ charts: { type: "single" as const, data: "" },
+ criteria: { key: "lamp" as const, value: 7, mode: "single" as const },
+ };
+
+ beforeEach(async () => {
+ ({ id: userID, username } = await seedUser({ username: `goal_update_${Date.now()}` }));
+ chartId = await seedMinimalIidxSpChart();
+ taker = { ip: "127.0.0.1", acct: { id: userID, username } };
+ });
+
+ it("throws 403 when targeting another user as non-admin", async () => {
+ const other = await seedUser({ username: `goal_upd_other_${Date.now()}` });
+
+ await expect(
+ ACTION_UpdateGoalSubscription(taker, {
+ ...baseInput,
+ userID: other.id,
+ charts: { type: "single", data: chartId },
+ oldGoalID: "G_fake",
+ }),
+ ).rejects.toMatchObject({ code: 403 });
+ });
+
+ it("throws 404 when the user is not subscribed to the old goal", async () => {
+ await expect(
+ ACTION_UpdateGoalSubscription(taker, {
+ ...baseInput,
+ userID,
+ charts: { type: "single", data: chartId },
+ oldGoalID: "G_does_not_exist",
+ }),
+ ).rejects.toMatchObject({ code: 404 });
+ });
+
+ it("throws 400 when the goal was assigned by a quest (not standalone)", async () => {
+ const { goalID } = await ACTION_AddGoal(taker, {
+ ...baseInput,
+ userID,
+ charts: { type: "single", data: chartId },
+ });
+
+ await DB.updateTable("goal_sub")
+ .set({ was_assigned_standalone: false })
+ .where("goal_sub.goal_id", "=", goalID)
+ .where("goal_sub.user_id", "=", userID)
+ .execute();
+
+ const secondChart = await seedMinimalIidxSpChart();
+
+ await expect(
+ ACTION_UpdateGoalSubscription(taker, {
+ ...baseInput,
+ userID,
+ oldGoalID: goalID,
+ charts: { type: "single", data: secondChart },
+ }),
+ ).rejects.toMatchObject({ code: 400 });
+ });
+
+ it("returns changed: false when the new definition is identical to the old one", async () => {
+ const { goalID } = await ACTION_AddGoal(taker, {
+ ...baseInput,
+ userID,
+ charts: { type: "single", data: chartId },
+ });
+
+ const result = await ACTION_UpdateGoalSubscription(taker, {
+ ...baseInput,
+ userID,
+ oldGoalID: goalID,
+ charts: { type: "single", data: chartId },
+ });
+
+ expect(result).toMatchObject({ changed: false, newGoalID: goalID });
+
+ const sub = await GetGoalSubscriptionForIDGuaranteed(goalID, userID);
+ expect(sub).toBeDefined();
+ });
+
+ it("swaps subscription from old goal to new goal", async () => {
+ const { goalID: oldGoalID } = await ACTION_AddGoal(taker, {
+ ...baseInput,
+ userID,
+ charts: { type: "single", data: chartId },
+ });
+
+ const secondChart = await seedMinimalIidxSpChart();
+
+ const result = await ACTION_UpdateGoalSubscription(taker, {
+ ...baseInput,
+ userID,
+ oldGoalID,
+ charts: { type: "single", data: secondChart },
+ });
+
+ expect(result.changed).toBe(true);
+ expect(result.newGoalID).not.toBe(oldGoalID);
+
+ const newGoal = await GetGoalForIDGuaranteed(result.newGoalID);
+ expect(newGoal.charts).toMatchObject({ type: "single", data: secondChart });
+
+ const newSub = await GetGoalSubscriptionForIDGuaranteed(result.newGoalID, userID);
+ expect(newSub).toBeDefined();
+
+ const oldSub = await DB.selectFrom("goal_sub")
+ .selectAll()
+ .where("goal_sub.goal_id", "=", oldGoalID)
+ .where("goal_sub.user_id", "=", userID)
+ .executeTakeFirst();
+
+ expect(oldSub).toBeUndefined();
+ });
+
+ it("writes a GOOD action row on success", async () => {
+ const { goalID: oldGoalID } = await ACTION_AddGoal(taker, {
+ ...baseInput,
+ userID,
+ charts: { type: "single", data: chartId },
+ });
+
+ const secondChart = await seedMinimalIidxSpChart();
+
+ await ACTION_UpdateGoalSubscription(taker, {
+ ...baseInput,
+ userID,
+ oldGoalID,
+ charts: { type: "single", data: secondChart },
+ });
+
+ const actionRow = await DB.selectFrom("action")
+ .selectAll()
+ .where("kind", "=", "UPDATE_GOAL_SUBSCRIPTION")
+ .orderBy("action.ts_start", "desc")
+ .executeTakeFirstOrThrow();
+
+ expect(actionRow).toMatchObject({
+ result: "GOOD",
+ ip: "127.0.0.1",
+ user_id: userID,
+ });
+ });
+
+ it("throws 400 when the new chart does not exist", async () => {
+ const { goalID: oldGoalID } = await ACTION_AddGoal(taker, {
+ ...baseInput,
+ userID,
+ charts: { type: "single", data: chartId },
+ });
+
+ await expect(
+ ACTION_UpdateGoalSubscription(taker, {
+ ...baseInput,
+ userID,
+ oldGoalID,
+ charts: { type: "single", data: "C_fake_chart" },
+ }),
+ ).rejects.toMatchObject({ code: 400 });
+ });
+});
diff --git a/typescript/server/src/actions/update-goal-subscription.ts b/typescript/server/src/actions/update-goal-subscription.ts
new file mode 100644
index 000000000..c1ea2a9c1
--- /dev/null
+++ b/typescript/server/src/actions/update-goal-subscription.ts
@@ -0,0 +1,73 @@
+import { MakeAction } from "#lib/actions/actions";
+import { SubscribeFailReasons } from "#lib/constants/err-codes";
+import { SELECT_GOAL_SUB_WITH_GOAL_GAME } from "#lib/db-formats/goal";
+import { ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents";
+import { ConstructGoal, SubscribeToGoal } from "#lib/targets/goals";
+import DB from "#services/pg/db";
+import { IsUserAdmin } from "#utils/user";
+import { ExpectedErr } from "bliss";
+import { type GoalDocument } from "tachi-common";
+
+export const ACTION_UpdateGoalSubscription = MakeAction(
+ "UPDATE_GOAL_SUBSCRIPTION",
+ async (taker, input) => {
+ const { userID, game, oldGoalID, charts, criteria } = input;
+
+ if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) {
+ throw new ExpectedErr(403, "You are not authorised to modify this user's goals.");
+ }
+
+ const existingSubRow = await DB.selectFrom("goal_sub")
+ .innerJoin("goal", "goal.id", "goal_sub.goal_id")
+ .select(SELECT_GOAL_SUB_WITH_GOAL_GAME)
+ .where("goal_sub.user_id", "=", userID)
+ .where("goal_sub.goal_id", "=", oldGoalID)
+ .where("goal.game", "=", game)
+ .executeTakeFirst();
+
+ if (!existingSubRow) {
+ throw new ExpectedErr(404, "You are not subscribed to this goal.");
+ }
+
+ const existingSub = ToGoalSubscriptionDocument(existingSubRow);
+
+ if (!existingSub.wasAssignedStandalone) {
+ throw new ExpectedErr(
+ 400,
+ "This goal was assigned by a quest and cannot be updated directly. Unsubscribe from the quest to modify its goals.",
+ );
+ }
+
+ let newGoal: GoalDocument;
+
+ try {
+ newGoal = await ConstructGoal(
+ charts as GoalDocument["charts"],
+ criteria as GoalDocument["criteria"],
+ game,
+ );
+ } catch (e) {
+ throw new ExpectedErr(400, (e as Error).message);
+ }
+
+ if (newGoal.goalID === oldGoalID) {
+ return { newGoalID: oldGoalID, changed: false };
+ }
+
+ await DB.deleteFrom("goal_sub")
+ .where("goal_sub.goal_id", "=", oldGoalID)
+ .where("goal_sub.user_id", "=", userID)
+ .execute();
+
+ const subResult = await SubscribeToGoal(userID, newGoal, true);
+
+ if (
+ subResult === SubscribeFailReasons.ALREADY_SUBSCRIBED ||
+ subResult === SubscribeFailReasons.ALREADY_ACHIEVED
+ ) {
+ throw new ExpectedErr(409, "Your updated goal conflicts with an existing subscription.");
+ }
+
+ return { newGoalID: newGoal.goalID, changed: true };
+ },
+);
diff --git a/typescript/server/src/lib/actions/actions.ts b/typescript/server/src/lib/actions/actions.ts
index 1dfd8605d..bc541ff2b 100644
--- a/typescript/server/src/lib/actions/actions.ts
+++ b/typescript/server/src/lib/actions/actions.ts
@@ -349,6 +349,19 @@ export const ActionSignatures = {
}),
output: z.object({}),
},
+ UPDATE_GOAL_SUBSCRIPTION: {
+ input: z.object({
+ userID: z.number().int(),
+ game: z.enum(ALL_GAMES),
+ oldGoalID: z.string(),
+ charts: z.any(),
+ criteria: z.any(),
+ }),
+ output: z.object({
+ newGoalID: z.string(),
+ changed: z.boolean(),
+ }),
+ },
UPDATE_UGPT_SHOWCASE: {
input: z.object({
userID: z.number().int(),
diff --git a/typescript/server/src/lib/targets/quests.ts b/typescript/server/src/lib/targets/quests.ts
index 22d3bbe9d..9176af625 100644
--- a/typescript/server/src/lib/targets/quests.ts
+++ b/typescript/server/src/lib/targets/quests.ts
@@ -50,6 +50,10 @@ export function GetGoalIDsFromQuest(quest: QuestDocument) {
export async function GetGoalsInQuest(quest: QuestDocument) {
const goalIDs = GetGoalIDsFromQuest(quest);
+ if (goalIDs.length === 0) {
+ return [];
+ }
+
const goals = await DB.selectFrom("goal")
.select(SELECT_GOAL)
.where("goal.id", "in", goalIDs)
@@ -124,7 +128,7 @@ export async function EvaluateQuestProgress(userID: integer, quest: QuestDocumen
const goalSubMap = new Map();
- if (isSubscribedToQuest) {
+ if (isSubscribedToQuest && goals.length > 0) {
const goalSubRows = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.select(SELECT_GOAL_SUB_WITH_GOAL_GAME)
@@ -382,16 +386,18 @@ export async function UnsubscribeFromQuest(
.where("quest_sub.user_id", "=", questSub.userID)
.execute();
- const goalSubRows = await DB.selectFrom("goal_sub")
- .innerJoin("goal", "goal.id", "goal_sub.goal_id")
- .select(SELECT_GOAL_SUB_WITH_GOAL_GAME)
- .where("goal_sub.user_id", "=", questSub.userID)
- .where("goal_sub.goal_id", "in", goalIDs)
- .execute();
+ if (goalIDs.length > 0) {
+ const goalSubRows = await DB.selectFrom("goal_sub")
+ .innerJoin("goal", "goal.id", "goal_sub.goal_id")
+ .select(SELECT_GOAL_SUB_WITH_GOAL_GAME)
+ .where("goal_sub.user_id", "=", questSub.userID)
+ .where("goal_sub.goal_id", "in", goalIDs)
+ .execute();
- const goalSubs = goalSubRows.map((r) => ToGoalSubscriptionDocument(r));
+ const goalSubs = goalSubRows.map((r) => ToGoalSubscriptionDocument(r));
- await Promise.all(goalSubs.map((e) => UnsubscribeFromGoal(e, true)));
+ await Promise.all(goalSubs.map((e) => UnsubscribeFromGoal(e, true)));
+ }
}
/**
diff --git a/typescript/server/src/scripts/load-seeds-pg.ts b/typescript/server/src/scripts/load-seeds-pg.ts
index 671861b43..bc2a3b22b 100644
--- a/typescript/server/src/scripts/load-seeds-pg.ts
+++ b/typescript/server/src/scripts/load-seeds-pg.ts
@@ -19,9 +19,9 @@
* promotes user #1 to admin so seed import can run.
*/
-import { buildChartIdMap, importSeeds, ImportSeedsSubsetForTests } from "../services/pg/seeds";
+import { buildChartIdMap, buildGoalIdRemap, importSeeds, ImportSeedsSubsetForTests } from "../services/pg/seeds";
-export { buildChartIdMap, importSeeds, ImportSeedsSubsetForTests as importSeedsSubset };
+export { buildChartIdMap, buildGoalIdRemap, importSeeds, ImportSeedsSubsetForTests as importSeedsSubset };
// ── Standalone entrypoint ──────────────────────────────────────────────────
diff --git a/typescript/server/src/scripts/migrate-to-postgres.ts b/typescript/server/src/scripts/migrate-to-postgres.ts
index db21952b0..6ad9fb9f5 100644
--- a/typescript/server/src/scripts/migrate-to-postgres.ts
+++ b/typescript/server/src/scripts/migrate-to-postgres.ts
@@ -104,7 +104,7 @@ import type {
MongoUserSettingsCollectionDocument,
} from "./migrate-to-postgres.mongo-docs";
-import { buildChartIdMap, importSeeds } from "./load-seeds-pg";
+import { buildChartIdMap, buildGoalIdRemap, importSeeds } from "./load-seeds-pg";
// ──────────────────────────────────────────────────────────────────────────────
// Connection setup
@@ -400,6 +400,8 @@ async function main(): Promise {
// Used to resolve chart_id FK references in scores and PBs.
const chartIdMap = buildChartIdMap(SEEDS_DIR);
+ const goalIdRemap = buildGoalIdRemap(SEEDS_DIR);
+
// ══════════════════════════════════════════════════════════════════════════
// LEVEL 0 - No FK dependencies
// ══════════════════════════════════════════════════════════════════════════
@@ -1164,15 +1166,18 @@ async function main(): Promise {
console.log("\n[goal_sub]");
const goalSubs = await mongoDB.get("goal-subs").find({});
- const uniqueGoalIds = [...new Set(goalSubs.map((gs) => gs.goalID))];
+ const uniqueMongoGoalIds = [...new Set(goalSubs.map((gs) => gs.goalID))];
+ const translatedIds = uniqueMongoGoalIds.map((gid) => goalIdRemap.get(gid) ?? gid);
+ const candidateGoalIdsForLookup = [...new Set([...uniqueMongoGoalIds, ...translatedIds])];
+
const existingGoalIds = new Set(
- uniqueGoalIds.length === 0
+ candidateGoalIdsForLookup.length === 0
? []
: (
await pg
.selectFrom("goal")
.select("id")
- .where("id", "in", uniqueGoalIds)
+ .where("id", "in", candidateGoalIdsForLookup)
.execute()
).map((r) => r.id),
);
@@ -1180,13 +1185,17 @@ async function main(): Promise {
const goalSubRows: Array = [];
for (const gs of goalSubs) {
- if (!existingGoalIds.has(gs.goalID)) {
- console.warn(` [goal_sub] Skipping - goal ${gs.goalID} not found in DB`);
+ const resolvedGoalId = goalIdRemap.get(gs.goalID) ?? gs.goalID;
+
+ if (!existingGoalIds.has(resolvedGoalId)) {
+ console.warn(
+ ` [goal_sub] Skipping - goal ${gs.goalID} (resolved=${resolvedGoalId}) not found in DB`,
+ );
continue;
}
goalSubRows.push({
- goal_id: gs.goalID,
+ goal_id: resolvedGoalId,
user_id: gs.userID,
last_interaction: ts(gs.lastInteraction),
progress: gs.progress,
diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/goals/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/goals/router.test.ts
index 794ac27bb..772e6b0e5 100644
--- a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/goals/router.test.ts
+++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/goals/router.test.ts
@@ -1,5 +1,216 @@
-import { describe, it } from "vitest";
+import DB from "#services/pg/db";
+import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
+import { seedMinimalIidxSpChart, seedUser } from "#test-utils/pg-fixtures";
+import { afterAll, describe, expect, it } from "vitest";
-describe("Game-group targets goals", () => {
- it.todo("port router.oldtest.ts");
+afterAll(() => CloseServerConnection());
+
+async function seedGoalWithSub(chartId: string, userId: number) {
+ const goalId = `G_test_${Date.now()}_${Math.random().toString(36).slice(2)}`;
+
+ await DB.insertInto("goal")
+ .values({
+ id: goalId,
+ game: "iidx-sp",
+ name: "Test Goal",
+ charts: JSON.stringify({ type: "single", data: chartId }),
+ criteria: JSON.stringify({ mode: "single", key: "lamp", value: 7 }),
+ })
+ .execute();
+
+ await DB.insertInto("goal_sub")
+ .values({
+ goal_id: goalId,
+ user_id: userId,
+ achieved: false,
+ time_achieved: null,
+ progress: null,
+ progress_human: "NO DATA",
+ out_of: 7,
+ out_of_human: "FULL COMBO",
+ last_interaction: null,
+ was_instantly_achieved: false,
+ was_assigned_standalone: true,
+ })
+ .execute();
+
+ return goalId;
+}
+
+describe("GET /api/v1/games/:game/targets/goals/popular", () => {
+ it("returns 200 with an array of goals", async () => {
+ const res = await mockApi.get("/api/v1/games/iidx-sp/targets/goals/popular");
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(Array.isArray(res.body.body)).toBe(true);
+ });
+
+ it("returns the most-subscribed goals sorted by popularity", async () => {
+ const chartId = await seedMinimalIidxSpChart();
+ const goalId = `G_pop_${Date.now()}`;
+
+ await DB.insertInto("goal")
+ .values({
+ id: goalId,
+ game: "iidx-sp",
+ name: "Popular Goal",
+ charts: JSON.stringify({ type: "single", data: chartId }),
+ criteria: JSON.stringify({ mode: "single", key: "lamp", value: 7 }),
+ })
+ .execute();
+
+ const { id: uid1 } = await seedUser({ username: `pop_u1_${Date.now()}` });
+ const { id: uid2 } = await seedUser({ username: `pop_u2_${Date.now()}` });
+
+ for (const uid of [uid1, uid2]) {
+ await DB.insertInto("goal_sub").values({
+ goal_id: goalId,
+ user_id: uid,
+ achieved: false,
+ time_achieved: null,
+ progress: null,
+ progress_human: "NO DATA",
+ out_of: 7,
+ out_of_human: "FULL COMBO",
+ last_interaction: null,
+ was_instantly_achieved: false,
+ was_assigned_standalone: true,
+ }).execute();
+ }
+
+ const res = await mockApi.get("/api/v1/games/iidx-sp/targets/goals/popular");
+
+ expect(res.status).toBe(200);
+
+ const goalIds = (res.body.body as Array<{ goalID: string }>).map((g) => g.goalID);
+
+ expect(goalIds).toContain(goalId);
+ });
+});
+
+describe("POST /api/v1/games/:game/targets/goals/format", () => {
+ it("returns a human-readable goal name for valid input", async () => {
+ const chartId = await seedMinimalIidxSpChart();
+
+ const res = await mockApi
+ .post("/api/v1/games/iidx-sp/targets/goals/format")
+ .send({
+ charts: { type: "single", data: chartId },
+ criteria: { mode: "single", key: "lamp", value: 7 },
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(typeof res.body.body).toBe("string");
+ expect(res.body.body.length).toBeGreaterThan(0);
+ });
+
+ it("returns 400 for an invalid chart reference", async () => {
+ const res = await mockApi
+ .post("/api/v1/games/iidx-sp/targets/goals/format")
+ .send({
+ charts: { type: "single", data: "C_does_not_exist" },
+ criteria: { mode: "single", key: "lamp", value: 7 },
+ });
+
+ expect(res.status).toBe(400);
+ expect(res.body.success).toBe(false);
+ });
+
+ it("returns 400 for an invalid criteria key", async () => {
+ const chartId = await seedMinimalIidxSpChart();
+
+ const res = await mockApi
+ .post("/api/v1/games/iidx-sp/targets/goals/format")
+ .send({
+ charts: { type: "single", data: chartId },
+ criteria: { mode: "single", key: "not_a_real_metric", value: 0 },
+ });
+
+ expect(res.status).toBe(400);
+ expect(res.body.success).toBe(false);
+ });
+});
+
+describe("GET /api/v1/games/:game/targets/goals/:goalID", () => {
+ it("returns goal + subscribers + parentQuests", async () => {
+ const chartId = await seedMinimalIidxSpChart();
+ const { id: detailUserId } = await seedUser({ username: `goal_detail_${Date.now()}` });
+ const goalId = await seedGoalWithSub(chartId, detailUserId);
+
+ const res = await mockApi.get(`/api/v1/games/iidx-sp/targets/goals/${goalId}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(res.body.body.goal.goalID).toBe(goalId);
+ expect(Array.isArray(res.body.body.goalSubs)).toBe(true);
+ expect(Array.isArray(res.body.body.users)).toBe(true);
+ expect(Array.isArray(res.body.body.parentQuests)).toBe(true);
+ });
+
+ it("returns 404 when the goal does not exist", async () => {
+ const res = await mockApi.get("/api/v1/games/iidx-sp/targets/goals/G_no_such_goal");
+
+ expect(res.status).toBe(404);
+ expect(res.body.success).toBe(false);
+ });
+
+ it("returns 404 when the goal belongs to a different game", async () => {
+ const chartId = await seedMinimalIidxSpChart();
+ const { id: gameCheckUserId } = await seedUser({ username: `goal_game_check_${Date.now()}` });
+ const goalId = await seedGoalWithSub(chartId, gameCheckUserId);
+
+ const res = await mockApi.get(`/api/v1/games/sdvx/targets/goals/${goalId}`);
+
+ expect(res.status).toBe(404);
+ });
+
+ it("parentQuestlines on quest detail returns proper QuestlineDocument shape", async () => {
+ const suffix = `${Date.now()}`;
+ const qlId = `ql-goal-test-${suffix}`;
+ const qId = `q-goal-test-${suffix}`;
+ const chartId = await seedMinimalIidxSpChart();
+ const goalId = `G_ql_shape_${suffix}`;
+
+ await DB.insertInto("goal").values({
+ id: goalId,
+ game: "iidx-sp",
+ name: "Shape test goal",
+ charts: JSON.stringify({ type: "single", data: chartId }),
+ criteria: JSON.stringify({ mode: "single", key: "lamp", value: 4 }),
+ }).execute();
+
+ await DB.insertInto("quest").values({
+ id: qId,
+ game: "iidx-sp",
+ name: "Shape test quest",
+ description: "d",
+ quest_data: JSON.stringify([{ title: "s", goals: [{ goalID: goalId }] }]),
+ }).execute();
+
+ await DB.insertInto("questline").values({
+ id: qlId,
+ game: "iidx-sp",
+ name: "Shape test questline",
+ description: "d",
+ }).execute();
+
+ await DB.insertInto("questline_quest").values({
+ questline_id: qlId,
+ quest_id: qId,
+ sort_order: 0,
+ }).execute();
+
+ const res = await mockApi.get(`/api/v1/games/iidx-sp/targets/quests/${qId}`);
+
+ expect(res.status).toBe(200);
+
+ const ql = (res.body.body.parentQuestlines as Array<{ questlineID: string; game: string; quests: string[] }>)
+ .find((x) => x.questlineID === qlId);
+
+ expect(ql).toBeDefined();
+ expect(ql?.game).toBe("iidx-sp");
+ expect(Array.isArray(ql?.quests)).toBe(true);
+ });
});
diff --git a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/quests/router.test.ts b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/quests/router.test.ts
index 198c3cb3f..bad58c294 100644
--- a/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/quests/router.test.ts
+++ b/typescript/server/src/server/router/api/v1/games/_gameGroup/_playtype/targets/quests/router.test.ts
@@ -1,5 +1,150 @@
-import { describe, it } from "vitest";
+import DB from "#services/pg/db";
+import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
+import { seedMinimalIidxSpChart } from "#test-utils/pg-fixtures";
+import { afterAll, describe, expect, it } from "vitest";
-describe("Game-group targets quests", () => {
- it.todo("port router.oldtest.ts");
+afterAll(() => CloseServerConnection());
+
+async function seedQuest(suffix: string, goalId?: string) {
+ const questId = `q-rt-${suffix}`;
+ const questData = goalId
+ ? [{ title: "Section", goals: [{ goalID: goalId }] }]
+ : [];
+
+ await DB.insertInto("quest")
+ .values({
+ id: questId,
+ game: "iidx-sp",
+ name: `Quest ${suffix}`,
+ description: `Description for ${suffix}`,
+ quest_data: JSON.stringify(questData),
+ })
+ .execute();
+
+ return questId;
+}
+
+describe("GET /api/v1/games/:game/targets/quests", () => {
+ it("returns quests matching the search term", async () => {
+ const suffix = `${Date.now()}`;
+ const questId = await seedQuest(suffix);
+
+ const res = await mockApi.get(
+ `/api/v1/games/iidx-sp/targets/quests?search=Quest+${suffix}`,
+ );
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+
+ const ids = (res.body.body.quests as Array<{ questID: string }>).map((q) => q.questID);
+
+ expect(ids).toContain(questId);
+ });
+
+ it("returns empty arrays when no quests match the search", async () => {
+ const res = await mockApi.get(
+ "/api/v1/games/iidx-sp/targets/quests?search=this_will_never_match_zzz_xyz",
+ );
+
+ expect(res.status).toBe(200);
+ expect(res.body.body.quests).toHaveLength(0);
+ expect(res.body.body.goals).toHaveLength(0);
+ });
+
+ it("returns associated goals in the response", async () => {
+ const chartId = await seedMinimalIidxSpChart();
+ const goalId = `G_q_search_${Date.now()}`;
+ const suffix = `${Date.now()}-g`;
+
+ await DB.insertInto("goal").values({
+ id: goalId,
+ game: "iidx-sp",
+ name: "Search goal",
+ charts: JSON.stringify({ type: "single", data: chartId }),
+ criteria: JSON.stringify({ mode: "single", key: "lamp", value: 4 }),
+ }).execute();
+
+ const questId = await seedQuest(suffix, goalId);
+
+ const res = await mockApi.get(
+ `/api/v1/games/iidx-sp/targets/quests?search=Quest+${suffix}`,
+ );
+
+ expect(res.status).toBe(200);
+
+ const questIds = (res.body.body.quests as Array<{ questID: string }>).map((q) => q.questID);
+ const goalIds = (res.body.body.goals as Array<{ goalID: string }>).map((g) => g.goalID);
+
+ expect(questIds).toContain(questId);
+ expect(goalIds).toContain(goalId);
+ });
+});
+
+describe("GET /api/v1/games/:game/targets/quests/:questID", () => {
+ it("returns quest + subscribers + goals + parentQuestlines", async () => {
+ const suffix = `${Date.now()}-d`;
+ const questId = await seedQuest(suffix);
+
+ const res = await mockApi.get(`/api/v1/games/iidx-sp/targets/quests/${questId}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(res.body.body.quest.questID).toBe(questId);
+ expect(Array.isArray(res.body.body.questSubs)).toBe(true);
+ expect(Array.isArray(res.body.body.users)).toBe(true);
+ expect(Array.isArray(res.body.body.goals)).toBe(true);
+ expect(Array.isArray(res.body.body.parentQuestlines)).toBe(true);
+ });
+
+ it("returns 404 when quest does not exist", async () => {
+ const res = await mockApi.get("/api/v1/games/iidx-sp/targets/quests/q_no_such_quest");
+
+ expect(res.status).toBe(404);
+ expect(res.body.success).toBe(false);
+ });
+
+ it("returns 404 when quest belongs to a different game", async () => {
+ const suffix = `${Date.now()}-x`;
+ const questId = await seedQuest(suffix);
+
+ const res = await mockApi.get(`/api/v1/games/sdvx/targets/quests/${questId}`);
+
+ expect(res.status).toBe(404);
+ });
+
+ it("parentQuestlines entries have V3Game shape (not legacy gameGroup+playtype)", async () => {
+ const suffix = `${Date.now()}-v3`;
+ const qlId = `ql-v3-${suffix}`;
+ const questId = await seedQuest(suffix);
+
+ await DB.insertInto("questline").values({
+ id: qlId,
+ game: "iidx-sp",
+ name: "V3 test questline",
+ description: "d",
+ }).execute();
+
+ await DB.insertInto("questline_quest").values({
+ questline_id: qlId,
+ quest_id: questId,
+ sort_order: 0,
+ }).execute();
+
+ const res = await mockApi.get(`/api/v1/games/iidx-sp/targets/quests/${questId}`);
+
+ expect(res.status).toBe(200);
+
+ const ql = (
+ res.body.body.parentQuestlines as Array<{ questlineID: string; game: string; quests: string[] }>
+ ).find((x) => x.questlineID === qlId);
+
+ expect(ql).toBeDefined();
+ // game should be the V3Game string, not a legacy GameGroup
+ expect(ql?.game).toBe("iidx-sp");
+ // must NOT have a top-level playtype field
+ expect((ql as any)?.playtype).toBeUndefined();
+ // must carry the quests array
+ expect(Array.isArray(ql?.quests)).toBe(true);
+ expect(ql?.quests).toContain(questId);
+ });
});
diff --git a/typescript/server/src/server/router/api/v1/games/router.ts b/typescript/server/src/server/router/api/v1/games/router.ts
index cd7f54eae..0b6ac216a 100644
--- a/typescript/server/src/server/router/api/v1/games/router.ts
+++ b/typescript/server/src/server/router/api/v1/games/router.ts
@@ -11,7 +11,6 @@ import {
LoadPbsOnChartForUserSearch,
} from "#lib/db-formats/pb";
import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/quest";
-import { SELECT_QUESTLINE_ROW } from "#lib/db-formats/questline";
import { GetSongByID, GetSongsByIDs } from "#lib/db-formats/song";
import {
GetTableDocumentsForGame,
@@ -54,7 +53,11 @@ import {
} from "#utils/db";
import { EscapeForILIKE, IsString } from "#utils/misc";
import { FindChartsOnPopularity } from "#utils/queries/charts";
-import { GetQuestlineById, GetQuestlinesForGame } from "#utils/queries/questlines";
+import {
+ GetQuestlineById,
+ GetQuestlinesForGame,
+ GetQuestlinesThatContainQuest,
+} from "#utils/queries/questlines";
import {
CheckStrProfileAlg,
CheckStrScoreAlg,
@@ -673,9 +676,11 @@ API_V1_ROUTER.add("GET /games/:game/targets/recently-raised", withGame, async ({
* @name GET /api/v1/games/:game/targets/goals/popular
*/
API_V1_ROUTER.add("GET /games/:game/targets/goals/popular", withGame, async ({ ctx }) => {
- const { gameGroup: _gameGroup, playtype } = LEGACY_GameToGameGroupPT(ctx.game);
+ const { gameGroup, playtype } = LEGACY_GameToGameGroupPT(ctx.game);
- const goals = await GetMostSubscribedGoals({ game: ctx.game, playtype });
+ // GetMostSubscribedGoals internally converts GameGroup+playtype to V3Game for filtering
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const goals = await GetMostSubscribedGoals({ game: gameGroup as any, playtype });
return success(`Returned ${goals.length} goals.`, goals);
});
@@ -800,23 +805,7 @@ API_V1_ROUTER.add("GET /games/:game/targets/quests/:questID", withGame, async ({
const users = await GetUsersWithIDs(questSubs.map((e) => e.userID));
const goals = await GetGoalsInQuest(quest);
- const qlRows = await DB.selectFrom("questline")
- .innerJoin("questline_quest", "questline_quest.questline_id", "questline.id")
- .select(SELECT_QUESTLINE_ROW)
- .where("questline_quest.quest_id", "=", quest.questID)
- .execute();
-
- const parentQuestlines = qlRows.map((ql) => {
- const { gameGroup: gg, playtype: pt } = LEGACY_GameToGameGroupPT(ql.game);
-
- return {
- desc: ql.description,
- game: gg,
- name: ql.name,
- playtype: pt,
- questlineID: ql.id,
- };
- });
+ const parentQuestlines = await GetQuestlinesThatContainQuest(quest.questID);
return success(`Retrieved information about ${quest.name}.`, {
goals,
diff --git a/typescript/server/src/server/router/api/v1/spec.ts b/typescript/server/src/server/router/api/v1/spec.ts
index e589d9344..df53a8616 100644
--- a/typescript/server/src/server/router/api/v1/spec.ts
+++ b/typescript/server/src/server/router/api/v1/spec.ts
@@ -1174,6 +1174,19 @@ export const API_V1_SPEC = {
}),
},
+ "PUT /users/:userID/games/:game/targets/goals/:goalID": {
+ description: "Update a standalone goal subscription by replacing its definition.",
+ input: z.object({
+ criteria: z.unknown(),
+ charts: z.unknown(),
+ }),
+ output: z.strictObject({
+ changed: z.boolean(),
+ goal: doc(),
+ goalSub: doc(),
+ }),
+ },
+
"DELETE /users/:userID/games/:game/targets/goals/:goalID": {
description: "Unsubscribe from a goal.",
input: z.object({}),
@@ -1187,7 +1200,7 @@ export const API_V1_SPEC = {
output: z.strictObject({
questSubs: docArray(),
quests: docArray(),
- goals: z.record(z.string(), docArray()),
+ goals: docArray(),
}),
},
diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts
index 1187bdd33..e119ae8c6 100644
--- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts
+++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts
@@ -1,7 +1,399 @@
-import { describe, it } from "vitest";
+import { newGameProfilePreferenceColumns } from "#lib/game-settings/create-game-settings";
+import DB from "#services/pg/db";
+import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
+import { seedMinimalIidxSpChart, seedUser } from "#test-utils/pg-fixtures";
+import { afterAll, beforeEach, describe, expect, it } from "vitest";
-describe("UGPT targets goals", () => {
- it.todo(
- "port large router.oldtest.ts (GET goals, POST add-goal, …) with Postgres goal/goal_sub",
- );
+afterAll(() => CloseServerConnection());
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+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[];
+}
+
+async function seedGameProfile(userId: number) {
+ await DB.insertInto("game_profile")
+ .values({
+ user_id: userId,
+ game: "iidx-sp",
+ ratings: JSON.stringify({}),
+ classes: JSON.stringify({}),
+ ...newGameProfilePreferenceColumns("iidx-sp"),
+ })
+ .execute();
+}
+
+// ─── GET goals ────────────────────────────────────────────────────────────────
+
+describe("GET /api/v1/users/:userID/games/:game/targets/goals", () => {
+ let userId: number;
+
+ beforeEach(async () => {
+ const suffix = Math.random().toString(36).slice(2, 10);
+ ({ id: userId } = await seedUser({
+ username: `goals_get_${suffix}`,
+ withCredential: true,
+ withSettings: true,
+ }));
+ await seedGameProfile(userId);
+ });
+
+ it("returns 200 with empty arrays when user has no goals", async () => {
+ const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/targets/goals`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(res.body.body.goals).toHaveLength(0);
+ expect(res.body.body.goalSubs).toHaveLength(0);
+ });
+
+ it("returns the user's subscribed goals when they have some", async () => {
+ const chartId = await seedMinimalIidxSpChart();
+ const goalId = `G_get_${Date.now()}`;
+
+ await DB.insertInto("goal").values({
+ id: goalId,
+ game: "iidx-sp",
+ name: "Listed goal",
+ charts: JSON.stringify({ type: "single", data: chartId }),
+ criteria: JSON.stringify({ mode: "single", key: "lamp", value: 7 }),
+ }).execute();
+
+ await DB.insertInto("goal_sub").values({
+ goal_id: goalId,
+ user_id: userId,
+ achieved: false,
+ time_achieved: null,
+ progress: null,
+ progress_human: "NO DATA",
+ out_of: 7,
+ out_of_human: "FULL COMBO",
+ last_interaction: null,
+ was_instantly_achieved: false,
+ was_assigned_standalone: true,
+ }).execute();
+
+ const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/targets/goals`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.body.goalSubs.length).toBeGreaterThanOrEqual(1);
+ });
+});
+
+// ─── POST add-goal ────────────────────────────────────────────────────────────
+
+describe("POST /api/v1/users/:userID/games/:game/targets/goals/add-goal", () => {
+ let userId: number;
+ let cookie: string[];
+
+ beforeEach(async () => {
+ const suffix = Math.random().toString(36).slice(2, 10);
+ const { username } = await seedUser({
+ username: `goals_add_${suffix}`,
+ withCredential: true,
+ withSettings: true,
+ });
+
+ // seedUser returns the inserted id but we need to look it up since id is serial
+ const row = await DB.selectFrom("account")
+ .select("account.id")
+ .where("account.username", "=", username)
+ .executeTakeFirstOrThrow();
+
+ userId = Number(row.id);
+ cookie = await loginAs(username);
+ await seedGameProfile(userId);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const chartId = await seedMinimalIidxSpChart();
+
+ const res = await mockApi
+ .post(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/add-goal`)
+ .send({
+ charts: { type: "single", data: chartId },
+ criteria: { mode: "single", key: "lamp", value: 7 },
+ });
+
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 200 and creates the goal + sub when input is valid", async () => {
+ const chartId = await seedMinimalIidxSpChart();
+
+ const res = await mockApi
+ .post(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/add-goal`)
+ .set("Cookie", cookie)
+ .send({
+ charts: { type: "single", data: chartId },
+ criteria: { mode: "single", key: "lamp", value: 7 },
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(res.body.body.goal.game).toBe("iidx-sp");
+ expect(res.body.body.goalSub.userID).toBe(userId);
+ });
+
+ it("returns 409 when the user is already subscribed to that goal", async () => {
+ const chartId = await seedMinimalIidxSpChart();
+ const body = {
+ charts: { type: "single", data: chartId },
+ criteria: { mode: "single", key: "lamp", value: 7 },
+ };
+
+ await mockApi
+ .post(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/add-goal`)
+ .set("Cookie", cookie)
+ .send(body);
+
+ const res = await mockApi
+ .post(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/add-goal`)
+ .set("Cookie", cookie)
+ .send(body);
+
+ expect(res.status).toBe(409);
+ expect(res.body.success).toBe(false);
+ });
+});
+
+// ─── GET single goal ──────────────────────────────────────────────────────────
+
+describe("GET /api/v1/users/:userID/games/:game/targets/goals/:goalID", () => {
+ let userId: number;
+
+ beforeEach(async () => {
+ const suffix = Math.random().toString(36).slice(2, 10);
+ ({ id: userId } = await seedUser({ username: `goals_single_${suffix}` }));
+ await seedGameProfile(userId);
+ });
+
+ it("returns 404 when user is not subscribed to the goal", async () => {
+ const res = await mockApi.get(
+ `/api/v1/users/${userId}/games/iidx-sp/targets/goals/G_no_such`,
+ );
+
+ expect(res.status).toBe(404);
+ });
+
+ it("returns the goal sub when subscribed", async () => {
+ const chartId = await seedMinimalIidxSpChart();
+ const goalId = `G_single_${Date.now()}`;
+
+ await DB.insertInto("goal").values({
+ id: goalId,
+ game: "iidx-sp",
+ name: "Single test goal",
+ charts: JSON.stringify({ type: "single", data: chartId }),
+ criteria: JSON.stringify({ mode: "single", key: "lamp", value: 4 }),
+ }).execute();
+
+ await DB.insertInto("goal_sub").values({
+ goal_id: goalId,
+ user_id: userId,
+ achieved: false,
+ time_achieved: null,
+ progress: null,
+ progress_human: "NO DATA",
+ out_of: 4,
+ out_of_human: "HARD CLEAR",
+ last_interaction: null,
+ was_instantly_achieved: false,
+ was_assigned_standalone: true,
+ }).execute();
+
+ const res = await mockApi.get(
+ `/api/v1/users/${userId}/games/iidx-sp/targets/goals/${goalId}`,
+ );
+
+ expect(res.status).toBe(200);
+ expect(res.body.body.goal.goalID).toBe(goalId);
+ expect(res.body.body.goalSub.userID).toBe(userId);
+ });
+});
+
+// ─── PUT update goal ──────────────────────────────────────────────────────────
+
+describe("PUT /api/v1/users/:userID/games/:game/targets/goals/:goalID", () => {
+ let userId: number;
+ let cookie: string[];
+
+ beforeEach(async () => {
+ const suffix = Math.random().toString(36).slice(2, 10);
+ const { username } = await seedUser({
+ username: `goals_put_${suffix}`,
+ withCredential: true,
+ withSettings: true,
+ });
+
+ const row = await DB.selectFrom("account")
+ .select("account.id")
+ .where("account.username", "=", username)
+ .executeTakeFirstOrThrow();
+
+ userId = Number(row.id);
+ cookie = await loginAs(username);
+ await seedGameProfile(userId);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await mockApi
+ .put(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/G_fake`)
+ .send({
+ charts: { type: "single", data: "C_fake" },
+ criteria: { mode: "single", key: "lamp", value: 7 },
+ });
+
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 404 when not subscribed to the old goal", async () => {
+ const chartId = await seedMinimalIidxSpChart();
+
+ const res = await mockApi
+ .put(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/G_nonexistent`)
+ .set("Cookie", cookie)
+ .send({
+ charts: { type: "single", data: chartId },
+ criteria: { mode: "single", key: "lamp", value: 7 },
+ });
+
+ expect(res.status).toBe(404);
+ });
+
+ it("swaps the goal subscription and returns the new goal", async () => {
+ const chartA = await seedMinimalIidxSpChart();
+ const chartB = await seedMinimalIidxSpChart();
+
+ // Add initial goal via the add-goal endpoint
+ const addRes = await mockApi
+ .post(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/add-goal`)
+ .set("Cookie", cookie)
+ .send({
+ charts: { type: "single", data: chartA },
+ criteria: { mode: "single", key: "lamp", value: 4 },
+ });
+
+ expect(addRes.status).toBe(200);
+
+ const oldGoalID = addRes.body.body.goal.goalID as string;
+
+ // Update to a different chart
+ const putRes = await mockApi
+ .put(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/${oldGoalID}`)
+ .set("Cookie", cookie)
+ .send({
+ charts: { type: "single", data: chartB },
+ criteria: { mode: "single", key: "lamp", value: 4 },
+ });
+
+ expect(putRes.status).toBe(200);
+ expect(putRes.body.success).toBe(true);
+ expect(putRes.body.body.changed).toBe(true);
+ expect(putRes.body.body.goal.charts).toMatchObject({ type: "single", data: chartB });
+ expect(putRes.body.body.goalSub.userID).toBe(userId);
+
+ // Old sub should be gone
+ const oldSub = await DB.selectFrom("goal_sub")
+ .selectAll()
+ .where("goal_sub.goal_id", "=", oldGoalID)
+ .where("goal_sub.user_id", "=", userId)
+ .executeTakeFirst();
+
+ expect(oldSub).toBeUndefined();
+ });
+
+ it("returns changed: false when the definition is unchanged", async () => {
+ const chartId = await seedMinimalIidxSpChart();
+
+ const addRes = await mockApi
+ .post(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/add-goal`)
+ .set("Cookie", cookie)
+ .send({
+ charts: { type: "single", data: chartId },
+ criteria: { mode: "single", key: "lamp", value: 4 },
+ });
+
+ const goalID = addRes.body.body.goal.goalID as string;
+
+ const putRes = await mockApi
+ .put(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/${goalID}`)
+ .set("Cookie", cookie)
+ .send({
+ charts: { type: "single", data: chartId },
+ criteria: { mode: "single", key: "lamp", value: 4 },
+ });
+
+ expect(putRes.status).toBe(200);
+ expect(putRes.body.body.changed).toBe(false);
+ expect(putRes.body.body.goal.goalID).toBe(goalID);
+ });
+});
+
+// ─── DELETE goal ──────────────────────────────────────────────────────────────
+
+describe("DELETE /api/v1/users/:userID/games/:game/targets/goals/:goalID", () => {
+ let userId: number;
+ let cookie: string[];
+
+ beforeEach(async () => {
+ const suffix = Math.random().toString(36).slice(2, 10);
+ const { username } = await seedUser({
+ username: `goals_del_${suffix}`,
+ withCredential: true,
+ withSettings: true,
+ });
+
+ const row = await DB.selectFrom("account")
+ .select("account.id")
+ .where("account.username", "=", username)
+ .executeTakeFirstOrThrow();
+
+ userId = Number(row.id);
+ cookie = await loginAs(username);
+ await seedGameProfile(userId);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await mockApi.delete(
+ `/api/v1/users/${userId}/games/iidx-sp/targets/goals/G_fake`,
+ );
+
+ expect(res.status).toBe(401);
+ });
+
+ it("removes the goal sub and returns 200", async () => {
+ const chartId = await seedMinimalIidxSpChart();
+
+ const addRes = await mockApi
+ .post(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/add-goal`)
+ .set("Cookie", cookie)
+ .send({
+ charts: { type: "single", data: chartId },
+ criteria: { mode: "single", key: "lamp", value: 4 },
+ });
+
+ const goalID = addRes.body.body.goal.goalID as string;
+
+ const delRes = await mockApi
+ .delete(`/api/v1/users/${userId}/games/iidx-sp/targets/goals/${goalID}`)
+ .set("Cookie", cookie);
+
+ expect(delRes.status).toBe(200);
+
+ const sub = await DB.selectFrom("goal_sub")
+ .selectAll()
+ .where("goal_sub.goal_id", "=", goalID)
+ .where("goal_sub.user_id", "=", userId)
+ .executeTakeFirst();
+
+ expect(sub).toBeUndefined();
+ });
});
diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts
index 6a8a4faf6..5f0b537f3 100644
--- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts
+++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts
@@ -144,6 +144,56 @@ API_V1_ROUTER.add(
},
);
+/**
+ * Update a standalone goal subscription by replacing its definition.
+ *
+ * Creates a new goal from the provided charts/criteria and atomically swaps
+ * the user's subscription from the old goal to the new one. Progress is
+ * re-evaluated from scores on the next import — no migration is needed.
+ *
+ * Only standalone goals (not quest-assigned goals) may be updated this way.
+ *
+ * @name PUT /api/v1/users/:userID/games/:game/targets/goals/:goalID
+ */
+API_V1_ROUTER.add(
+ "PUT /users/:userID/games/:game/targets/goals/:goalID",
+ withUserGameProfile,
+ async ({ ctx, params, input, req }) => {
+ const { requestedUser: user, game } = ctx;
+
+ const sessionUser = req.session.tachi?.user;
+
+ if (!sessionUser) {
+ throw new ExpectedErr(401, "You are not authenticated.");
+ }
+
+ const taker = { acct: { id: sessionUser.id, username: sessionUser.username }, ip: req.ip };
+
+ const { ACTION_UpdateGoalSubscription } = await import(
+ "#actions/update-goal-subscription"
+ );
+ const { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } = await import(
+ "#utils/db"
+ );
+
+ const { newGoalID, changed } = await ACTION_UpdateGoalSubscription(taker, {
+ charts: input.charts as GoalDocument["charts"],
+ criteria: input.criteria as GoalDocument["criteria"],
+ game,
+ oldGoalID: params.goalID,
+ userID: user.id,
+ });
+
+ const newGoal = await GetGoalForIDGuaranteed(newGoalID);
+ const newGoalSub = await GetGoalSubscriptionForIDGuaranteed(newGoalID, user.id);
+
+ return success(
+ changed ? `Updated goal to '${newGoal.name}'.` : "Goal definition is unchanged.",
+ { changed, goal: newGoal, goalSub: newGoalSub },
+ );
+ },
+);
+
/**
* Unsubscribe from a goal.
*
diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.test.ts
index 80a676845..712d5ea30 100644
--- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.test.ts
+++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.test.ts
@@ -1,5 +1,306 @@
-import { describe, it } from "vitest";
+import { newGameProfilePreferenceColumns } from "#lib/game-settings/create-game-settings";
+import DB from "#services/pg/db";
+import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
+import { seedUser } from "#test-utils/pg-fixtures";
+import { afterAll, beforeEach, describe, expect, it } from "vitest";
-describe("UGPT targets quests", () => {
- it.todo("port router.oldtest.ts (GET/PATCH quest routes) with Postgres quest/quest_sub");
+afterAll(() => CloseServerConnection());
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+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[];
+}
+
+async function seedGameProfile(userId: number) {
+ await DB.insertInto("game_profile")
+ .values({
+ user_id: userId,
+ game: "iidx-sp",
+ ratings: JSON.stringify({}),
+ classes: JSON.stringify({}),
+ ...newGameProfilePreferenceColumns("iidx-sp"),
+ })
+ .execute();
+}
+
+async function seedQuest(suffix: string) {
+ const questId = `q-ugpt-${suffix}`;
+
+ await DB.insertInto("quest")
+ .values({
+ id: questId,
+ game: "iidx-sp",
+ name: `UGPT Quest ${suffix}`,
+ description: "Test quest",
+ quest_data: JSON.stringify([]),
+ })
+ .execute();
+
+ return questId;
+}
+
+// ─── GET quests ───────────────────────────────────────────────────────────────
+
+describe("GET /api/v1/users/:userID/games/:game/targets/quests", () => {
+ let userId: number;
+
+ beforeEach(async () => {
+ const suffix = Math.random().toString(36).slice(2, 10);
+ ({ id: userId } = await seedUser({ username: `q_get_${suffix}` }));
+ await seedGameProfile(userId);
+ });
+
+ it("returns 200 with empty arrays when user has no quest subscriptions", async () => {
+ const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/targets/quests`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(res.body.body.quests).toHaveLength(0);
+ expect(res.body.body.questSubs).toHaveLength(0);
+ expect(res.body.body.goals).toHaveLength(0);
+ });
+
+ it("returns subscribed quests and their goals", async () => {
+ const suffix = `${Date.now()}`;
+ const questId = await seedQuest(suffix);
+
+ await DB.insertInto("quest_sub")
+ .values({
+ quest_id: questId,
+ user_id: userId,
+ achieved: false,
+ time_achieved: null,
+ progress: 0,
+ last_interaction: null,
+ was_instantly_achieved: false,
+ })
+ .execute();
+
+ const res = await mockApi.get(`/api/v1/users/${userId}/games/iidx-sp/targets/quests`);
+
+ expect(res.status).toBe(200);
+
+ const questIds = (res.body.body.quests as Array<{ questID: string }>).map((q) => q.questID);
+
+ expect(questIds).toContain(questId);
+ });
+});
+
+// ─── GET single quest ─────────────────────────────────────────────────────────
+
+describe("GET /api/v1/users/:userID/games/:game/targets/quests/:questID", () => {
+ let userId: number;
+
+ beforeEach(async () => {
+ const suffix = Math.random().toString(36).slice(2, 10);
+ ({ id: userId } = await seedUser({ username: `q_single_${suffix}` }));
+ await seedGameProfile(userId);
+ });
+
+ it("returns 404 when user is not subscribed", async () => {
+ const suffix = `${Date.now()}-ns`;
+ const questId = await seedQuest(suffix);
+
+ const res = await mockApi.get(
+ `/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`,
+ );
+
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 404 when quest does not exist", async () => {
+ const res = await mockApi.get(
+ `/api/v1/users/${userId}/games/iidx-sp/targets/quests/q_no_such`,
+ );
+
+ expect(res.status).toBe(404);
+ });
+
+ it("returns quest progress when subscribed", async () => {
+ const suffix = `${Date.now()}-sub`;
+ const questId = await seedQuest(suffix);
+
+ await DB.insertInto("quest_sub")
+ .values({
+ quest_id: questId,
+ user_id: userId,
+ achieved: false,
+ time_achieved: null,
+ progress: 0,
+ last_interaction: null,
+ was_instantly_achieved: false,
+ })
+ .execute();
+
+ const res = await mockApi.get(
+ `/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`,
+ );
+
+ expect(res.status).toBe(200);
+ expect(res.body.body.quest.questID).toBe(questId);
+ expect(res.body.body.questSub.userID).toBe(userId);
+ expect(Array.isArray(res.body.body.goals)).toBe(true);
+ expect(Array.isArray(res.body.body.results)).toBe(true);
+ });
+});
+
+// ─── PUT subscribe ────────────────────────────────────────────────────────────
+
+describe("PUT /api/v1/users/:userID/games/:game/targets/quests/:questID", () => {
+ let userId: number;
+ let cookie: string[];
+
+ beforeEach(async () => {
+ const suffix = Math.random().toString(36).slice(2, 10);
+ const { username } = await seedUser({
+ username: `q_put_${suffix}`,
+ withCredential: true,
+ withSettings: true,
+ });
+
+ const row = await DB.selectFrom("account")
+ .select("account.id")
+ .where("account.username", "=", username)
+ .executeTakeFirstOrThrow();
+
+ userId = Number(row.id);
+ cookie = await loginAs(username);
+ await seedGameProfile(userId);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const suffix = `${Date.now()}-auth`;
+ const questId = await seedQuest(suffix);
+
+ const res = await mockApi.put(
+ `/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`,
+ );
+
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 404 when quest does not exist", async () => {
+ const res = await mockApi
+ .put(`/api/v1/users/${userId}/games/iidx-sp/targets/quests/q_no_such`)
+ .set("Cookie", cookie);
+
+ expect(res.status).toBe(404);
+ });
+
+ it("subscribes the user to the quest and returns 200", async () => {
+ const suffix = `${Date.now()}-ok`;
+ const questId = await seedQuest(suffix);
+
+ const res = await mockApi
+ .put(`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`)
+ .set("Cookie", cookie);
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(res.body.body.quest.questID).toBe(questId);
+
+ const sub = await DB.selectFrom("quest_sub")
+ .selectAll()
+ .where("quest_sub.quest_id", "=", questId)
+ .where("quest_sub.user_id", "=", userId)
+ .executeTakeFirst();
+
+ expect(sub).toBeDefined();
+ });
+
+ it("returns 409 with 'quest' (not 'goal') when already subscribed", async () => {
+ const suffix = `${Date.now()}-dup`;
+ const questId = await seedQuest(suffix);
+
+ await mockApi
+ .put(`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`)
+ .set("Cookie", cookie);
+
+ const res = await mockApi
+ .put(`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`)
+ .set("Cookie", cookie);
+
+ expect(res.status).toBe(409);
+ // The error message must mention "quest", not "goal" (regression guard for the bug fix)
+ expect(res.body.description).toMatch(/quest/i);
+ expect(res.body.description).not.toMatch(/goal/i);
+ });
+});
+
+// ─── DELETE unsubscribe ───────────────────────────────────────────────────────
+
+describe("DELETE /api/v1/users/:userID/games/:game/targets/quests/:questID", () => {
+ let userId: number;
+ let cookie: string[];
+
+ beforeEach(async () => {
+ const suffix = Math.random().toString(36).slice(2, 10);
+ const { username } = await seedUser({
+ username: `q_del_${suffix}`,
+ withCredential: true,
+ withSettings: true,
+ });
+
+ const row = await DB.selectFrom("account")
+ .select("account.id")
+ .where("account.username", "=", username)
+ .executeTakeFirstOrThrow();
+
+ userId = Number(row.id);
+ cookie = await loginAs(username);
+ await seedGameProfile(userId);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const suffix = `${Date.now()}-dauth`;
+ const questId = await seedQuest(suffix);
+
+ const res = await mockApi.delete(
+ `/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`,
+ );
+
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 409 when not subscribed", async () => {
+ const suffix = `${Date.now()}-dns`;
+ const questId = await seedQuest(suffix);
+
+ const res = await mockApi
+ .delete(`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`)
+ .set("Cookie", cookie);
+
+ expect(res.status).toBe(409);
+ });
+
+ it("removes the quest sub and returns 200", async () => {
+ const suffix = `${Date.now()}-dok`;
+ const questId = await seedQuest(suffix);
+
+ // Subscribe first
+ await mockApi
+ .put(`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`)
+ .set("Cookie", cookie);
+
+ const delRes = await mockApi
+ .delete(`/api/v1/users/${userId}/games/iidx-sp/targets/quests/${questId}`)
+ .set("Cookie", cookie);
+
+ expect(delRes.status).toBe(200);
+
+ const sub = await DB.selectFrom("quest_sub")
+ .selectAll()
+ .where("quest_sub.quest_id", "=", questId)
+ .where("quest_sub.user_id", "=", userId)
+ .executeTakeFirst();
+
+ expect(sub).toBeUndefined();
+ });
});
diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts
index 7bfe2e3b2..998174737 100644
--- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts
+++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts
@@ -102,9 +102,13 @@ API_V1_ROUTER.add(
API_V1_ROUTER.add(
"PUT /users/:userID/games/:game/targets/quests/:questID",
withUserGameProfile,
- async ({ ctx, params }) => {
+ async ({ ctx, params, req }) => {
const { requestedUser: user, game } = ctx;
+ if (!req.session.tachi?.user) {
+ throw new ExpectedErr(401, "You are not authenticated.");
+ }
+
const { SubscribeFailReasons } = await import("#lib/constants/err-codes");
const { SubscribeToQuest } = await import("#lib/targets/quests");
const { ServerConfig } = await import("#lib/setup/config");
@@ -146,7 +150,7 @@ API_V1_ROUTER.add(
.executeTakeFirst();
if (alreadySubscribed) {
- throw new ExpectedErr(409, "You are already subscribed to this goal.");
+ throw new ExpectedErr(409, "You are already subscribed to this quest.");
}
const subResult = await SubscribeToQuest(user.id, quest, false);
@@ -167,9 +171,13 @@ API_V1_ROUTER.add(
API_V1_ROUTER.add(
"DELETE /users/:userID/games/:game/targets/quests/:questID",
withUserGameProfile,
- async ({ ctx, params }) => {
+ async ({ ctx, params, req }) => {
const { requestedUser: user, game } = ctx;
+ if (!req.session.tachi?.user) {
+ throw new ExpectedErr(401, "You are not authenticated.");
+ }
+
const { UnsubscribeFromQuest } = await import("#lib/targets/quests");
const questRow = await DB.selectFrom("quest")
diff --git a/typescript/server/src/services/pg/seeds.ts b/typescript/server/src/services/pg/seeds.ts
index 84c0adf85..8c810a533 100644
--- a/typescript/server/src/services/pg/seeds.ts
+++ b/typescript/server/src/services/pg/seeds.ts
@@ -296,6 +296,22 @@ export function buildChartIdMap(seedsDir: string): Map {
return map;
}
+/**
+ * Optional map from `rerunners/v3/7-remap-goals-folder-and-chart-ids.ts` (`db/seeds/goal-id-remap.json`).
+ * Mongo `goal-subs.goalID` may still use hashes from before legacy chart/folder ids were rewired.
+ */
+export function buildGoalIdRemap(seedsDir: string): Map {
+ const p = path.join(seedsDir, "goal-id-remap.json");
+
+ if (!fs.existsSync(p)) {
+ return new Map();
+ }
+
+ const raw = JSON.parse(fs.readFileSync(p, "utf-8")) as Record;
+
+ return new Map(Object.entries(raw));
+}
+
// ── Core import logic ──────────────────────────────────────────────────────
export async function importSeeds(pg: Kysely, seedsDir: string): Promise {
diff --git a/typescript/server/src/utils/queries/questlines.ts b/typescript/server/src/utils/queries/questlines.ts
index 143f277f1..696757693 100644
--- a/typescript/server/src/utils/queries/questlines.ts
+++ b/typescript/server/src/utils/queries/questlines.ts
@@ -40,6 +40,51 @@ export async function GetQuestlinesForGame(game: V3Game): Promise ToQuestlineDocument(row, byQuestline.get(row.questline_id) ?? []));
}
+/**
+ * All questlines that contain a given quest, with `quests` ordered by `sort_order`.
+ */
+export async function GetQuestlinesThatContainQuest(
+ questID: string,
+): Promise> {
+ const memberRows = await DB.selectFrom("questline_quest")
+ .select("questline_quest.questline_id")
+ .where("questline_quest.quest_id", "=", questID)
+ .execute();
+
+ if (memberRows.length === 0) {
+ return [];
+ }
+
+ const questlineIds = memberRows.map((r) => r.questline_id);
+
+ const qlRows = await DB.selectFrom("questline")
+ .select(SELECT_QUESTLINE)
+ .where("questline.id", "in", questlineIds)
+ .execute();
+
+ const qqRows = await DB.selectFrom("questline_quest")
+ .select([
+ "questline_quest.questline_id",
+ "questline_quest.quest_id",
+ "questline_quest.sort_order",
+ ])
+ .where("questline_quest.questline_id", "in", questlineIds)
+ .orderBy("questline_quest.questline_id")
+ .orderBy("questline_quest.sort_order")
+ .execute();
+
+ const byQuestline = new Map>();
+
+ for (const row of qqRows) {
+ const list = byQuestline.get(row.questline_id) ?? [];
+
+ list.push(row.quest_id);
+ byQuestline.set(row.questline_id, list);
+ }
+
+ return qlRows.map((row) => ToQuestlineDocument(row, byQuestline.get(row.questline_id) ?? []));
+}
+
/**
* One questline by id, scoped to game. `quests` are ordered by `questline_quest.sort_order`.
*/