dasdatmeespresso

This commit is contained in:
zk
2026-05-16 19:53:23 +00:00
parent 8a125634a8
commit acea12ec8a
73 changed files with 5248 additions and 2090 deletions
+7
View File
@@ -50,3 +50,10 @@ TACHI_NAME=Tachi Local Dev
TACHI_TYPE=omni
TACHI_GAME_GROUPS=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai,maimaidx,arcaea,ongeki
TACHI_IMPORT_TYPES=file/eamusement-iidx-csv,file/batch-manual,file/solid-state-squad,file/pli-iidx-csv,ir/direct-manual,ir/barbatos,ir/fervidex,ir/fervidex-static,ir/beatoraja,ir/usc,ir/kshook-sv6c,api/eag-iidx,api/eag-sdvx,api/flo-iidx,api/flo-sdvx,api/min-sdvx
# TACHI_GITHUB_APP_ID=
# TACHI_GITHUB_APP_BASE64_PRIVATE_KEY=
# TACHI_GITHUB_REPO_OWNER=
# TACHI_GITHUB_REPO_NAME=
# TACHI_GITHUB_INSTALLATION_ID=
# TACHI_GITHUB_WEBHOOK_SECRET=
+3 -2
View File
@@ -32,6 +32,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "catalog:",
"@octokit/app": "^16.1.2",
"@types/bcryptjs": "catalog:",
"@types/bunyan": "catalog:",
"@types/connect-redis": "catalog:",
@@ -53,6 +54,7 @@
"bunyan": "catalog:",
"commander": "catalog:",
"connect-redis": "catalog:",
"cron-parser": "catalog:",
"csv-parse": "catalog:",
"deepmerge": "catalog:",
"dotenv": "catalog:",
@@ -85,8 +87,7 @@
"tachi-db-migration-engine": "workspace:*",
"ts-node": "catalog:",
"typescript": "catalog:",
"zod": "catalog:",
"cron-parser": "catalog:"
"zod": "catalog:"
},
"nyc": {
"reporter": [
@@ -0,0 +1,29 @@
import { MakeAction } from "#lib/actions/actions";
import DB from "#services/pg/db";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
export const ACTION_SetUserQuestSubmitterStatus = MakeAction(
"SET_USER_QUEST_SUBMITTER_STATUS",
async (taker, { userID, canSubmit }) => {
if (!(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorized to perform this action.");
}
const existing = await DB.selectFrom("account")
.select("id")
.where("id", "=", userID)
.executeTakeFirst();
if (!existing) {
throw new ExpectedErr(404, "This user does not exist.");
}
await DB.updateTable("account")
.set({ can_submit_quests: canSubmit })
.where("id", "=", userID)
.execute();
return {};
},
);
@@ -10,7 +10,7 @@ describe("ACTION_UpdateGoalSubscription", () => {
let userID: number;
let username: string;
let chartId: string;
let taker: { ip: string; acct: { id: number; username: string } };
let taker: { acct: { id: number; username: string }; ip: string };
const baseInput = {
game: "iidx-sp" as const,
@@ -65,7 +65,10 @@ export const ACTION_UpdateGoalSubscription = MakeAction(
subResult === SubscribeFailReasons.ALREADY_SUBSCRIBED ||
subResult === SubscribeFailReasons.ALREADY_ACHIEVED
) {
throw new ExpectedErr(409, "Your updated goal conflicts with an existing subscription.");
throw new ExpectedErr(
409,
"Your updated goal conflicts with an existing subscription.",
);
}
return { newGoalID: newGoal.goalID, changed: true };
@@ -312,6 +312,13 @@ export const ActionSignatures = {
}),
output: z.object({}),
},
SET_USER_QUEST_SUBMITTER_STATUS: {
input: z.object({
userID: z.number().int().positive(),
canSubmit: z.boolean(),
}),
output: z.object({}),
},
PATCH_UGPT_SETTINGS: {
input: z.object({
userID: z.number().int(),
@@ -25,6 +25,7 @@ export const SELECT_USER = [
"account.username",
"account.normalized_username",
"account.is_supporter",
"account.can_submit_quests",
] as const;
export function ToUserDocument(
@@ -63,5 +64,6 @@ export function ToUserDocument(
username: row.username,
usernameLowercase: row.normalized_username,
isSupporter: row.is_supporter,
canSubmitQuests: row.can_submit_quests,
};
}
@@ -40,7 +40,7 @@ function buildList(): Array<CronTaskDef> {
},
{
id: "deorphan_scores",
schedule: "1 0 * * *",
schedule: "0 1 * * *",
description: "De-Orphan Scores",
run: DeorphanScoresMain,
},
@@ -0,0 +1,203 @@
/**
* Converts the editor's "raw" quest format (inline goal definitions) into the
* canonical seeds format that lives in db/seeds/quests.json and db/seeds/goals.json.
*
* This mirrors the logic in typescript/seeds-scripts/rerunners/import-raw-quests.ts
* but runs inside the server so it can be called from the proposals API.
*/
import { CreateGoalID } from "#lib/targets/goals";
import fjsh from "fast-json-stable-hash";
import { type GoalDocument, type V3Game } from "tachi-common";
// Mirrors client/src/types/tachi.ts — kept local to avoid a cross-package dep.
export type RawQuestGoal = {
goal: Pick<GoalDocument, "charts" | "criteria" | "name">;
note?: string;
};
export type RawQuestSection = {
desc: string;
rawGoals: Array<RawQuestGoal>;
title: string;
};
export type RawQuestDocument = {
desc: string;
game: string;
name: string;
rawQuestData: Array<RawQuestSection>;
};
export type RawQuestlineDocument = {
desc: string;
/** V3Game identifier, e.g. "iidx-sp". */
game: string;
name: string;
questlineID: string;
quests: Array<string>;
};
// ─── Seeds-format types ────────────────────────────────────────────────────────
export type SeedsQuestGoalRef = {
goalID: string;
note?: string;
};
export type SeedsQuestSection = {
desc?: string;
goals: Array<SeedsQuestGoalRef>;
title: string;
};
/**
* Quest document shape expected by db/seeds/quests.json.
* Uses `game` as a V3Game (e.g. "iidx-sp"), matching SEEDS_QUEST_DOCUMENT_SCHEMA.
*/
export type SeedsQuestDocument = {
desc: string;
game: string;
name: string;
questData: Array<SeedsQuestSection>;
questID: string;
};
/**
* Questline document shape expected by db/seeds/questlines.json.
*/
export type SeedsQuestlineDocument = {
desc: string;
game: string;
name: string;
questlineID: string;
quests: Array<string>;
};
// ─── ID generation ────────────────────────────────────────────────────────────
/**
* Creates a deterministic questID by hashing the quest's stable identity.
* Using SHA-256 of { name, desc, game } so the same quest re-submitted by
* different authors gets the same ID, but any textual change produces a new one.
*/
export function CreateQuestID(name: string, desc: string, game: V3Game): string {
return fjsh.hash({ name, desc, game }, "sha256") as string;
}
// ─── Hydration ────────────────────────────────────────────────────────────────
/**
* Converts an array of RawQuestDocuments (editor format) to the seeds format.
* Returns the fully-resolved quests and the new goals that need to be merged
* into db/seeds/goals.json.
*/
export function hydrateRawQuests(raws: Array<RawQuestDocument>): {
goals: Array<GoalDocument>;
quests: Array<SeedsQuestDocument>;
} {
const newGoals: Array<GoalDocument> = [];
const seenGoalIDs = new Set<string>();
const quests: Array<SeedsQuestDocument> = raws.map((raw) => {
const v3Game = raw.game as V3Game;
const questData: Array<SeedsQuestSection> = raw.rawQuestData.map((section) => {
const goals: Array<SeedsQuestGoalRef> = section.rawGoals.map((rawGoal) => {
const goalID = CreateGoalID(rawGoal.goal.charts, rawGoal.goal.criteria, v3Game);
if (!seenGoalIDs.has(goalID)) {
seenGoalIDs.add(goalID);
newGoals.push({
charts: rawGoal.goal.charts,
criteria: rawGoal.goal.criteria,
game: v3Game,
goalID,
name: rawGoal.goal.name,
} as GoalDocument);
}
return { goalID, ...(rawGoal.note !== undefined ? { note: rawGoal.note } : {}) };
});
return {
title: section.title,
...(section.desc ? { desc: section.desc } : {}),
goals,
};
});
return {
questID: CreateQuestID(raw.name, raw.desc, v3Game),
name: raw.name,
desc: raw.desc,
game: v3Game,
questData,
};
});
return { quests, goals: newGoals };
}
/**
* Converts RawQuestlineDocuments to seeds format, replacing quest names with
* the questIDs that hydrateRawQuests computed.
*
* @param questNameToID Map from the quest's original `name` to its computed questID.
*/
export function hydrateRawQuestlines(
raws: Array<RawQuestlineDocument>,
questNameToID: Map<string, string>,
): Array<SeedsQuestlineDocument> {
return raws.map((ql) => {
const questIDs = ql.quests
.map((name) => questNameToID.get(name))
.filter((id): id is string => id !== undefined);
return {
questlineID: ql.questlineID,
name: ql.name,
desc: ql.desc,
game: ql.game,
quests: questIDs,
};
});
}
/**
* Merges new quests/goals into the existing seed arrays (deduplicating by ID).
*/
export function mergeIntoSeeds(opts: {
existingGoals: Array<GoalDocument>;
existingQuestlines: Array<SeedsQuestlineDocument>;
existingQuests: Array<SeedsQuestDocument>;
newGoals: Array<GoalDocument>;
newQuestlines: Array<SeedsQuestlineDocument>;
newQuests: Array<SeedsQuestDocument>;
}): {
goals: Array<GoalDocument>;
questlines: Array<SeedsQuestlineDocument>;
quests: Array<SeedsQuestDocument>;
} {
const existingQuestIDs = new Set(opts.existingQuests.map((q) => q.questID));
const existingGoalIDs = new Set(opts.existingGoals.map((g) => g.goalID));
const existingQuestlineIDs = new Set(opts.existingQuestlines.map((ql) => ql.questlineID));
const mergedQuests = [
...opts.existingQuests,
...opts.newQuests.filter((q) => !existingQuestIDs.has(q.questID)),
];
const mergedGoals = [
...opts.existingGoals,
...opts.newGoals.filter((g) => !existingGoalIDs.has(g.goalID)),
];
const mergedQuestlines = [
...opts.existingQuestlines,
...opts.newQuestlines.filter((ql) => !existingQuestlineIDs.has(ql.questlineID)),
];
return { quests: mergedQuests, goals: mergedGoals, questlines: mergedQuestlines };
}
@@ -0,0 +1,221 @@
/**
* GitHub App helpers for the quest-proposal PR workflow.
*
* All functions require GITHUB_APP_CONFIG to be present in ServerConfig.
* Callers should check `ServerConfig.GITHUB_APP_CONFIG !== undefined` before
* calling these, and return 503 to the client if the config is absent.
*/
import { log } from "#lib/log/log";
import { ServerConfig } from "#lib/setup/config";
import { App } from "@octokit/app";
// Lazily-initialised singleton so we only create the App once per process.
let _app: App | null = null;
function getApp(): App {
if (_app !== null) {
return _app;
}
const cfg = ServerConfig.GITHUB_APP_CONFIG;
if (!cfg) {
throw new Error("GITHUB_APP_CONFIG is not configured. Cannot use GitHub App.");
}
_app = new App({
appId: cfg.APP_ID,
privateKey: cfg.PRIVATE_KEY,
});
return _app;
}
/**
* Returns an installation-scoped Octokit that can act on the configured repo.
*/
export async function getInstallationOctokit() {
const cfg = ServerConfig.GITHUB_APP_CONFIG!;
const app = getApp();
return app.getInstallationOctokit(cfg.INSTALLATION_ID);
}
type OctokitInstance = Awaited<ReturnType<typeof getInstallationOctokit>>;
/**
* Reads a file from the default branch of the configured repo.
* Returns the decoded string content and the blob SHA (needed for updates).
*/
export async function readRepoFile(
octokit: OctokitInstance,
path: string,
): Promise<{ content: string; sha: string }> {
const cfg = ServerConfig.GITHUB_APP_CONFIG!;
const { data } = await octokit.request("GET /repos/{owner}/{repo}/contents/{path}", {
owner: cfg.REPO_OWNER,
repo: cfg.REPO_NAME,
path,
});
if (Array.isArray(data) || data.type !== "file") {
throw new Error(`Expected a file at ${path} but got a directory or unexpected response.`);
}
// GitHub returns base64-encoded content
const content = Buffer.from(data.content, "base64").toString("utf-8");
return { content, sha: data.sha };
}
/**
* Gets the SHA of the default branch's HEAD commit.
*/
async function getDefaultBranchSHA(octokit: OctokitInstance): Promise<string> {
const cfg = ServerConfig.GITHUB_APP_CONFIG!;
const { data: repoData } = await octokit.request("GET /repos/{owner}/{repo}", {
owner: cfg.REPO_OWNER,
repo: cfg.REPO_NAME,
});
const defaultBranch = repoData.default_branch;
const { data: refData } = await octokit.request("GET /repos/{owner}/{repo}/git/ref/{ref}", {
owner: cfg.REPO_OWNER,
repo: cfg.REPO_NAME,
ref: `heads/${defaultBranch}`,
});
return refData.object.sha;
}
type FileChange = {
content: string;
path: string;
};
/**
* Creates or force-updates a branch, commits the given file changes, and
* either opens a new PR or updates the body/title of an existing one.
*
* Returns the PR number.
*/
export async function openOrUpdateProposalPR(opts: {
branch: string;
changes: Array<FileChange>;
existingPrNumber?: number;
octokit: OctokitInstance;
prBody: string;
prTitle: string;
}): Promise<number> {
const { octokit, branch, prTitle, prBody, changes, existingPrNumber } = opts;
const cfg = ServerConfig.GITHUB_APP_CONFIG!;
const { REPO_OWNER: owner, REPO_NAME: repo } = cfg;
// 1. Get the base SHA from the default branch
const baseSHA = await getDefaultBranchSHA(octokit);
// 2. Create a new git tree with the file changes
const treeItems = await Promise.all(
changes.map(async (change) => ({
path: change.path,
mode: "100644" as const,
type: "blob" as const,
content: change.content,
})),
);
const { data: treeData } = await octokit.request("POST /repos/{owner}/{repo}/git/trees", {
owner,
repo,
base_tree: baseSHA,
tree: treeItems,
});
// 3. Create a commit on top of the base
const { data: commitData } = await octokit.request("POST /repos/{owner}/{repo}/git/commits", {
owner,
repo,
message: prTitle,
tree: treeData.sha,
parents: [baseSHA],
});
// 4. Create or force-update the branch ref
const refPath = `refs/heads/${branch}`;
try {
await octokit.request("POST /repos/{owner}/{repo}/git/refs", {
owner,
repo,
ref: refPath,
sha: commitData.sha,
});
} catch {
// Branch already exists — force-update it
await octokit.request("PATCH /repos/{owner}/{repo}/git/refs/{ref}", {
owner,
repo,
ref: `heads/${branch}`,
sha: commitData.sha,
force: true,
});
}
// 5. Open or update the PR
if (existingPrNumber !== undefined) {
await octokit.request("PATCH /repos/{owner}/{repo}/pulls/{pull_number}", {
owner,
repo,
pull_number: existingPrNumber,
title: prTitle,
body: prBody,
});
log.info({ prNumber: existingPrNumber, branch }, "Updated existing quest-proposal PR.");
return existingPrNumber;
}
// Get default branch name for base
const { data: repoData } = await octokit.request("GET /repos/{owner}/{repo}", {
owner,
repo,
});
const { data: prData } = await octokit.request("POST /repos/{owner}/{repo}/pulls", {
owner,
repo,
title: prTitle,
body: prBody,
head: branch,
base: repoData.default_branch,
});
log.info({ prNumber: prData.number, branch }, "Opened new quest-proposal PR.");
return prData.number;
}
/**
* Fetches the current state of a PR from GitHub.
* Returns "open", "merged", or "closed".
*/
export async function getPRStatus(prNumber: number): Promise<"closed" | "merged" | "open"> {
const cfg = ServerConfig.GITHUB_APP_CONFIG!;
const octokit = await getInstallationOctokit();
const { data } = await octokit.request("GET /repos/{owner}/{repo}/pulls/{pull_number}", {
owner: cfg.REPO_OWNER,
repo: cfg.REPO_NAME,
pull_number: prNumber,
});
if (data.merged_at !== null) {
return "merged";
}
return data.state as "closed" | "open";
}
+40
View File
@@ -89,6 +89,7 @@ const configSchema = z.object({
GAME_GROUPS: z.array(z.enum(allSupportedGameGroups as [GameGroup, ...GameGroup[]])),
IMPORT_TYPES: z.array(z.enum(allImportTypes as [ImportTypes, ...ImportTypes[]])),
SIGNUPS_ENABLED: z.boolean().default(true),
QUEST_PROPOSALS_ENABLED: z.boolean(),
}),
CDN_CONFIG: z.object({
WEB_LOCATION: z.string(),
@@ -110,6 +111,17 @@ const configSchema = z.object({
}),
])
.optional(),
GITHUB_APP_CONFIG: z
.object({
APP_ID: z.string(),
PRIVATE_KEY: z.string(),
INSTALLATION_ID: z.number().int(),
REPO_OWNER: z.string(),
REPO_NAME: z.string(),
/** Shared secret for the webhook endpoint that marks proposals as merged. */
WEBHOOK_SECRET: z.string(),
})
.optional(),
});
export type OAuth2Info = z.infer<typeof oauth2Schema>;
@@ -218,6 +230,31 @@ function s3SaveLocation(
};
}
function githubAppConfig(): TachiServerConfig["GITHUB_APP_CONFIG"] {
const appId = opt("TACHI_GITHUB_APP_ID");
if (appId === undefined) {
return undefined;
}
const b64Key = opt("TACHI_GITHUB_APP_BASE64_PRIVATE_KEY");
const owner = opt("TACHI_GITHUB_REPO_OWNER");
const repoName = opt("TACHI_GITHUB_REPO_NAME");
const installationId = opt("TACHI_GITHUB_INSTALLATION_ID");
const webhookSecret = opt("TACHI_GITHUB_WEBHOOK_SECRET");
if (!b64Key || !owner || !repoName || !installationId || !webhookSecret) {
throw new Error(
"TACHI_GITHUB_APP_ID is set but one or more of TACHI_GITHUB_APP_BASE64_PRIVATE_KEY, TACHI_GITHUB_REPO_OWNER, TACHI_GITHUB_REPO_NAME, TACHI_GITHUB_INSTALLATION_ID, TACHI_GITHUB_WEBHOOK_SECRET are missing.",
);
}
return {
APP_ID: appId,
PRIVATE_KEY: Buffer.from(b64Key, "base64").toString("utf-8"),
REPO_OWNER: owner,
REPO_NAME: repoName,
INSTALLATION_ID: Number.parseInt(installationId, 10),
WEBHOOK_SECRET: webhookSecret,
};
}
function seedsConfig(): TachiServerConfig["SEEDS_CONFIG"] {
const type = opt("TACHI_SEEDS_TYPE");
if (type === undefined) {
@@ -385,6 +422,7 @@ const emailCfg = emailConfig();
const inviteCfg = inviteCodeConfig();
const bootstrapInvite = opt("TACHI_INVITE_ADMIN_INITIAL_INVITE_CODE")?.trim() || undefined;
const seedsCfg = seedsConfig();
const githubAppCfg = githubAppConfig();
const clientDev = clientDevServer();
const extWorkerConc = opt("TACHI_EXTERNAL_SCORE_IMPORT_WORKER_CONCURRENCY");
@@ -435,6 +473,7 @@ const configFromEnv: unknown = {
GAME_GROUPS: gameGroups,
IMPORT_TYPES: importTypes,
SIGNUPS_ENABLED: parseBool("TACHI_SIGNUPS_ENABLED", true) ?? true,
QUEST_PROPOSALS_ENABLED: githubAppCfg !== undefined,
},
CDN_CONFIG: {
WEB_LOCATION: req("TACHI_CDN_WEB_LOCATION"),
@@ -442,6 +481,7 @@ const configFromEnv: unknown = {
SAVE_LOCATION_PRIVATE: s3SaveLocation("TACHI_CDN_SAVE_LOCATION_PRIVATE"),
},
...(seedsCfg !== undefined ? { SEEDS_CONFIG: seedsCfg } : {}),
...(githubAppCfg !== undefined ? { GITHUB_APP_CONFIG: githubAppCfg } : {}),
};
const result = configSchema.safeParse(configFromEnv);
+12 -2
View File
@@ -19,9 +19,19 @@
* promotes user #1 to admin so seed import can run.
*/
import { buildChartIdMap, buildGoalIdRemap, importSeeds, ImportSeedsSubsetForTests } from "../services/pg/seeds";
import {
buildChartIdMap,
buildGoalIdRemap,
importSeeds,
ImportSeedsSubsetForTests,
} from "../services/pg/seeds";
export { buildChartIdMap, buildGoalIdRemap, importSeeds, ImportSeedsSubsetForTests as importSeedsSubset };
export {
buildChartIdMap,
buildGoalIdRemap,
importSeeds,
ImportSeedsSubsetForTests as importSeedsSubset,
};
// ── Standalone entrypoint ──────────────────────────────────────────────────
@@ -1168,7 +1168,7 @@ async function main(): Promise<void> {
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 candidateGoalIdsForLookup = [...new Set([...translatedIds, ...uniqueMongoGoalIds])];
const existingGoalIds = new Set(
candidateGoalIdsForLookup.length === 0
+3 -1
View File
@@ -80,7 +80,9 @@ async function main(): Promise<void> {
process.exit(1);
}
const handler = authenticatedActionHandlers[actionName as ActionName] as AuthenticatedActionHandler;
const handler = authenticatedActionHandlers[
actionName as ActionName
] as AuthenticatedActionHandler;
let payload: Record<string, unknown> = {};
if (values["payload-file"]) {
@@ -1,6 +1,7 @@
import { ACTION_DeleteScore } from "#actions/delete-score";
import { ACTION_DeleteSession } from "#actions/delete-session";
import { ACTION_RebuildFolderChartLookup } from "#actions/rebuild-folder-chart-lookup";
import { ACTION_SetUserQuestSubmitterStatus } from "#actions/set-user-quest-submitter-status";
import { ACTION_SetUserSupporterStatus } from "#actions/set-user-supporter-status";
import {
GetActions,
@@ -184,6 +185,38 @@ API_V1_ROUTER.add("POST /admin/rebuild-folder-chart-lookup", withAdmin, async ({
);
});
API_V1_ROUTER.add("POST /admin/quest-submitter/:userID", withAdmin, async ({ params, req }) => {
const target = await ResolveUser(params.userID);
if (!target) {
throw new ExpectedErr(404, "This user does not exist.");
}
const adminUserID = req[SYMBOL_TACHI_API_AUTH].userID!;
const adminUser = await GetUserWithIDGuaranteed(adminUserID);
const taker = { acct: { id: adminUser.id, username: adminUser.username }, ip: req.ip };
await ACTION_SetUserQuestSubmitterStatus(taker, { canSubmit: true, userID: target.id });
return success("Done.", {});
});
API_V1_ROUTER.add("DELETE /admin/quest-submitter/:userID", withAdmin, async ({ params, req }) => {
const target = await ResolveUser(params.userID);
if (!target) {
throw new ExpectedErr(404, "This user does not exist.");
}
const adminUserID = req[SYMBOL_TACHI_API_AUTH].userID!;
const adminUser = await GetUserWithIDGuaranteed(adminUserID);
const taker = { acct: { id: adminUser.id, username: adminUser.username }, ip: req.ip };
await ACTION_SetUserQuestSubmitterStatus(taker, { canSubmit: false, userID: target.id });
return success("Done.", {});
});
API_V1_ROUTER.add("POST /admin/reprocess-all-goals", withAdmin, () => {
throw new ExpectedErr(501, "Not implemented.");
});
@@ -64,19 +64,21 @@ describe("GET /api/v1/games/:game/targets/goals/popular", () => {
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();
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");
@@ -93,12 +95,10 @@ 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 },
});
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);
@@ -107,12 +107,10 @@ describe("POST /api/v1/games/:game/targets/goals/format", () => {
});
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 },
});
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);
@@ -121,12 +119,10 @@ describe("POST /api/v1/games/:game/targets/goals/format", () => {
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 },
});
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);
@@ -158,7 +154,9 @@ describe("GET /api/v1/games/:game/targets/goals/:goalID", () => {
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 { 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}`);
@@ -173,41 +171,54 @@ describe("GET /api/v1/games/:game/targets/goals/:goalID", () => {
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("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("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")
.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();
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);
const ql = (
res.body.body.parentQuestlines as Array<{
game: string;
questlineID: string;
quests: string[];
}>
).find((x) => x.questlineID === qlId);
expect(ql).toBeDefined();
expect(ql?.game).toBe("iidx-sp");
@@ -7,9 +7,7 @@ afterAll(() => CloseServerConnection());
async function seedQuest(suffix: string, goalId?: string) {
const questId = `q-rt-${suffix}`;
const questData = goalId
? [{ title: "Section", goals: [{ goalID: goalId }] }]
: [];
const questData = goalId ? [{ title: "Section", goals: [{ goalID: goalId }] }] : [];
await DB.insertInto("quest")
.values({
@@ -56,13 +54,15 @@ describe("GET /api/v1/games/:game/targets/quests", () => {
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();
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);
@@ -117,25 +117,33 @@ describe("GET /api/v1/games/:game/targets/quests/:questID", () => {
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")
.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();
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[] }>
res.body.body.parentQuestlines as Array<{
game: string;
questlineID: string;
quests: string[];
}>
).find((x) => x.questlineID === qlId);
expect(ql).toBeDefined();
@@ -0,0 +1,630 @@
/**
* Quest Proposal API
*
* Allows authenticated users to submit quests from the editor as GitHub PRs.
* The server converts the raw (inlined-goal) editor format to the seeds format,
* opens/updates a PR via the GitHub App, and tracks the proposal in the DB.
*/
import type { GoalDocument } from "tachi-common";
import { log } from "#lib/log/log";
import {
hydrateRawQuestlines,
hydrateRawQuests,
mergeIntoSeeds,
type RawQuestDocument,
type RawQuestlineDocument,
type SeedsQuestDocument,
type SeedsQuestlineDocument,
} from "#lib/proposals/convert";
import {
getInstallationOctokit,
getPRStatus,
openOrUpdateProposalPR,
readRepoFile,
} from "#lib/proposals/github";
import { success } from "#lib/router/typed-router";
import { ServerConfig } from "#lib/setup/config";
import { API_V1_ROUTER } from "#server/router/api/v1/router";
import DB from "#services/pg/db";
import { ExpectedErr } from "bliss";
// ─── Shared helpers ────────────────────────────────────────────────────────────
type OctokitLikeError = { response?: { data?: { message?: string } }; status?: number } & Error;
/**
* Wraps a GitHub API call and converts Octokit RequestErrors into user-facing
* ExpectedErrs with the GitHub error message included.
*/
async function callGitHub<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (err) {
const e = err as OctokitLikeError;
if (e instanceof Error && typeof e.status === "number") {
const ghMsg = e.response?.data?.message ?? e.message;
throw new ExpectedErr(
502,
`GitHub API error (${e.status}): ${ghMsg}. Check that the GitHub App has Contents read/write and Pull requests read/write permissions on this repository.`,
);
}
throw err;
}
}
function requireGitHubConfig() {
if (!ServerConfig.GITHUB_APP_CONFIG) {
throw new ExpectedErr(503, "Quest proposals are not enabled on this instance.");
}
return ServerConfig.GITHUB_APP_CONFIG;
}
function requireSession(req: { session: { tachi?: { user?: { id: number; username: string } } } }) {
const user = req.session.tachi?.user;
if (!user) {
throw new ExpectedErr(401, "You must be logged in to manage quest proposals.");
}
return user;
}
function buildPRBody(
submitterUsername: string,
quests: Array<SeedsQuestDocument>,
questlines: Array<SeedsQuestlineDocument>,
): string {
const questList = quests.map((q) => `- **${q.name}** (${q.game})`).join("\n");
const qlList =
questlines.length > 0
? `\n\n### Questlines\n${questlines.map((ql) => `- **${ql.name}**`).join("\n")}`
: "";
return `## Quest Proposal
Submitted by @${submitterUsername} via the Tachi Quest Editor.
### Quests
${questList}${qlList}
---
_This PR was automatically generated. Please review the quest content and goals before merging._`;
}
// ─── POST /proposals — Submit a new quest as a PR ─────────────────────────────
/**
* Submit new quest(s) (and optional questlines) as a GitHub PR.
*
* @name POST /api/v1/proposals
*/
API_V1_ROUTER.add("POST /proposals", async ({ req, input }) => {
requireGitHubConfig();
const sessionUser = requireSession(req);
// Only users who have been approved for quest submission may open PRs.
const account = await DB.selectFrom("account")
.select(["can_submit_quests"])
.where("id", "=", sessionUser.id)
.executeTakeFirst();
if (!account?.can_submit_quests) {
throw new ExpectedErr(
403,
"You do not have permission to submit quests. Apply via Discord to get access.",
);
}
const {
quests: rawQuests,
questlines: rawQuestlines,
prTitle,
} = input as {
prTitle?: string;
questlines?: Array<RawQuestlineDocument>;
quests: Array<RawQuestDocument>;
};
if (rawQuests.length === 0) {
throw new ExpectedErr(400, "You must provide at least one quest.");
}
const { quests: seedQuests, goals: newGoals } = hydrateRawQuests(rawQuests);
const questNameToID = new Map(rawQuests.map((raw, i) => [raw.name, seedQuests[i]!.questID]));
const seedQuestlines = rawQuestlines ? hydrateRawQuestlines(rawQuestlines, questNameToID) : [];
const octokit = await callGitHub(() => getInstallationOctokit());
const cfg = ServerConfig.GITHUB_APP_CONFIG!;
// Read existing seed files from the repo
const { content: existingQuestsRaw } = await callGitHub(() =>
readRepoFile(octokit, "db/seeds/quests.json"),
);
const { content: existingGoalsRaw } = await callGitHub(() =>
readRepoFile(octokit, "db/seeds/goals.json"),
);
const { content: existingQuestlinesRaw } = await callGitHub(() =>
readRepoFile(octokit, "db/seeds/questlines.json"),
);
const existingQuests = JSON.parse(existingQuestsRaw) as Array<SeedsQuestDocument>;
const existingGoals = JSON.parse(existingGoalsRaw) as Array<GoalDocument>;
const existingQuestlines = JSON.parse(existingQuestlinesRaw) as Array<SeedsQuestlineDocument>;
const {
quests: mergedQuests,
goals: mergedGoals,
questlines: mergedQuestlines,
} = mergeIntoSeeds({
existingQuests,
existingGoals,
existingQuestlines,
newQuests: seedQuests,
newGoals,
newQuestlines: seedQuestlines,
});
const branchSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
const branch = `quest-proposal/${branchSuffix}`;
const questNames = seedQuests.map((q) => q.name).join(", ");
const title = prTitle?.trim() || `Add quest: ${questNames}`;
const prBody = buildPRBody(sessionUser.username, seedQuests, seedQuestlines);
const prNumber = await callGitHub(() =>
openOrUpdateProposalPR({
octokit,
branch,
prTitle: title,
prBody,
changes: [
{ path: "db/seeds/quests.json", content: JSON.stringify(mergedQuests, null, "\t") },
{ path: "db/seeds/goals.json", content: JSON.stringify(mergedGoals, null, "\t") },
...(mergedQuestlines !== existingQuestlines
? [
{
path: "db/seeds/questlines.json",
content: JSON.stringify(mergedQuestlines, null, "\t"),
},
]
: []),
],
}),
);
const prUrl = `https://github.com/${cfg.REPO_OWNER}/${cfg.REPO_NAME}/pull/${prNumber}`;
const row = await DB.insertInto("quest_proposal")
.values({
user_id: sessionUser.id,
github_pr_number: prNumber,
github_branch: branch,
raw_quests: JSON.stringify(rawQuests),
raw_questlines: JSON.stringify(rawQuestlines ?? []),
status: "open",
})
.returning([
"quest_proposal.row_id",
"quest_proposal.github_pr_number",
"quest_proposal.github_branch",
"quest_proposal.status",
"quest_proposal.created_at",
])
.executeTakeFirstOrThrow();
return success(`Opened quest proposal PR #${prNumber}.`, {
proposalID: row.row_id,
prNumber: row.github_pr_number,
prUrl,
status: row.status,
});
});
// ─── GET /proposals — List all open proposals ─────────────────────────────────
/**
* List all open quest proposals (paginated, newest first).
*
* @name GET /api/v1/proposals
*/
API_V1_ROUTER.add("GET /proposals", async ({ input }) => {
requireGitHubConfig();
const page = Math.max(0, Number(input.page ?? 0));
const limit = 20;
const rows = await DB.selectFrom("quest_proposal")
.innerJoin("account", "account.id", "quest_proposal.user_id")
.select([
"quest_proposal.row_id",
"quest_proposal.github_pr_number",
"quest_proposal.github_branch",
"quest_proposal.status",
"quest_proposal.raw_quests",
"quest_proposal.created_at",
"account.username",
])
.where("quest_proposal.status", "=", "open")
.orderBy("quest_proposal.created_at", "desc")
.limit(limit)
.offset(page * limit)
.execute();
const cfg = ServerConfig.GITHUB_APP_CONFIG!;
const proposals = rows.map((row) => {
const quests = (
typeof row.raw_quests === "string"
? (JSON.parse(row.raw_quests) as Array<RawQuestDocument>)
: (row.raw_quests as unknown as Array<RawQuestDocument>)
).map((q: RawQuestDocument) => ({ name: q.name, game: q.game }));
return {
proposalID: row.row_id,
prNumber: row.github_pr_number,
prUrl: `https://github.com/${cfg.REPO_OWNER}/${cfg.REPO_NAME}/pull/${row.github_pr_number}`,
status: row.status,
submitterUsername: row.username,
quests,
createdAt: row.created_at,
};
});
return success(`Retrieved ${proposals.length} proposals.`, { proposals, page });
});
// ─── GET /proposals/mine — Current user's proposals ───────────────────────────
/**
* List the calling user's quest proposals.
*
* @name GET /api/v1/proposals/mine
*/
API_V1_ROUTER.add("GET /proposals/mine", async ({ req }) => {
requireGitHubConfig();
const sessionUser = requireSession(req);
const cfg = ServerConfig.GITHUB_APP_CONFIG!;
const rows = await DB.selectFrom("quest_proposal")
.select([
"quest_proposal.row_id",
"quest_proposal.github_pr_number",
"quest_proposal.github_branch",
"quest_proposal.status",
"quest_proposal.raw_quests",
"quest_proposal.raw_questlines",
"quest_proposal.created_at",
"quest_proposal.updated_at",
])
.where("quest_proposal.user_id", "=", sessionUser.id)
.orderBy("quest_proposal.created_at", "desc")
.execute();
const proposals = rows.map((row) => ({
proposalID: row.row_id,
prNumber: row.github_pr_number,
prUrl: `https://github.com/${cfg.REPO_OWNER}/${cfg.REPO_NAME}/pull/${row.github_pr_number}`,
status: row.status,
rawQuests: typeof row.raw_quests === "string" ? JSON.parse(row.raw_quests) : row.raw_quests,
rawQuestlines:
typeof row.raw_questlines === "string"
? JSON.parse(row.raw_questlines)
: row.raw_questlines,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
return success(`Retrieved ${proposals.length} proposals.`, { proposals });
});
// ─── GET /proposals/:proposalID — Single proposal + live PR status ─────────────
/**
* Get a single quest proposal, including its live PR status fetched from GitHub.
*
* @name GET /api/v1/proposals/:proposalID
*/
API_V1_ROUTER.add("GET /proposals/:proposalID", async ({ params }) => {
requireGitHubConfig();
const cfg = ServerConfig.GITHUB_APP_CONFIG!;
const row = await DB.selectFrom("quest_proposal")
.innerJoin("account", "account.id", "quest_proposal.user_id")
.select([
"quest_proposal.row_id",
"quest_proposal.github_pr_number",
"quest_proposal.github_branch",
"quest_proposal.status",
"quest_proposal.raw_quests",
"quest_proposal.raw_questlines",
"quest_proposal.created_at",
"quest_proposal.updated_at",
"account.username",
])
.where("quest_proposal.row_id", "=", params.proposalID)
.executeTakeFirst();
if (!row) {
throw new ExpectedErr(404, `No proposal found with ID '${params.proposalID}'.`);
}
// Fetch live PR status from GitHub (unless already closed/merged)
let liveStatus: "closed" | "merged" | "open" = row.status as "closed" | "merged" | "open";
if (row.status === "open") {
try {
liveStatus = await getPRStatus(row.github_pr_number);
// Sync the DB if the status changed
if (liveStatus !== row.status) {
await DB.updateTable("quest_proposal")
.set({ status: liveStatus, updated_at: new Date().toISOString() })
.where("quest_proposal.row_id", "=", row.row_id)
.execute();
}
} catch (err) {
log.warn(
{ err, prNumber: row.github_pr_number },
"Failed to fetch live PR status from GitHub.",
);
}
}
return success(`Retrieved proposal ${params.proposalID}.`, {
proposalID: row.row_id,
prNumber: row.github_pr_number,
prUrl: `https://github.com/${cfg.REPO_OWNER}/${cfg.REPO_NAME}/pull/${row.github_pr_number}`,
status: liveStatus,
submitterUsername: row.username,
rawQuests: typeof row.raw_quests === "string" ? JSON.parse(row.raw_quests) : row.raw_quests,
rawQuestlines:
typeof row.raw_questlines === "string"
? JSON.parse(row.raw_questlines)
: row.raw_questlines,
createdAt: row.created_at,
updatedAt: row.updated_at,
});
});
// ─── PUT /proposals/:proposalID — Update quest data ────────────────────────────
/**
* Update the quest content of an existing proposal, pushing a new commit to
* the branch and updating the PR.
*
* @name PUT /api/v1/proposals/:proposalID
*/
API_V1_ROUTER.add("PUT /proposals/:proposalID", async ({ req, params, input }) => {
requireGitHubConfig();
const sessionUser = requireSession(req);
const cfg = ServerConfig.GITHUB_APP_CONFIG!;
const row = await DB.selectFrom("quest_proposal")
.select([
"quest_proposal.row_id",
"quest_proposal.github_pr_number",
"quest_proposal.github_branch",
"quest_proposal.status",
"quest_proposal.user_id",
])
.where("quest_proposal.row_id", "=", params.proposalID)
.executeTakeFirst();
if (!row) {
throw new ExpectedErr(404, `No proposal found with ID '${params.proposalID}'.`);
}
if (row.user_id !== sessionUser.id) {
throw new ExpectedErr(403, "You can only update your own proposals.");
}
if (row.status !== "open") {
throw new ExpectedErr(409, `Cannot update a proposal that is ${row.status}.`);
}
const {
quests: rawQuests,
questlines: rawQuestlines,
prTitle,
} = input as {
prTitle?: string;
questlines?: Array<RawQuestlineDocument>;
quests: Array<RawQuestDocument>;
};
const { quests: seedQuests, goals: newGoals } = hydrateRawQuests(rawQuests);
const questNameToID = new Map(rawQuests.map((raw, i) => [raw.name, seedQuests[i]!.questID]));
const seedQuestlines = rawQuestlines ? hydrateRawQuestlines(rawQuestlines, questNameToID) : [];
const octokit = await callGitHub(() => getInstallationOctokit());
const { content: existingQuestsRaw } = await callGitHub(() =>
readRepoFile(octokit, "db/seeds/quests.json"),
);
const { content: existingGoalsRaw } = await callGitHub(() =>
readRepoFile(octokit, "db/seeds/goals.json"),
);
const { content: existingQuestlinesRaw } = await callGitHub(() =>
readRepoFile(octokit, "db/seeds/questlines.json"),
);
const existingQuests = JSON.parse(existingQuestsRaw) as Array<SeedsQuestDocument>;
const existingGoals = JSON.parse(existingGoalsRaw) as Array<GoalDocument>;
const existingQuestlines = JSON.parse(existingQuestlinesRaw) as Array<SeedsQuestlineDocument>;
const {
quests: mergedQuests,
goals: mergedGoals,
questlines: mergedQuestlines,
} = mergeIntoSeeds({
existingQuests,
existingGoals,
existingQuestlines,
newQuests: seedQuests,
newGoals,
newQuestlines: seedQuestlines,
});
const questNames = seedQuests.map((q) => q.name).join(", ");
const title = prTitle?.trim() || `Add quest: ${questNames}`;
const prBody = buildPRBody(sessionUser.username, seedQuests, seedQuestlines);
await callGitHub(() =>
openOrUpdateProposalPR({
octokit,
branch: row.github_branch,
prTitle: title,
prBody,
changes: [
{ path: "db/seeds/quests.json", content: JSON.stringify(mergedQuests, null, "\t") },
{ path: "db/seeds/goals.json", content: JSON.stringify(mergedGoals, null, "\t") },
...(mergedQuestlines !== existingQuestlines
? [
{
path: "db/seeds/questlines.json",
content: JSON.stringify(mergedQuestlines, null, "\t"),
},
]
: []),
],
existingPrNumber: row.github_pr_number,
}),
);
await DB.updateTable("quest_proposal")
.set({
raw_quests: JSON.stringify(rawQuests),
raw_questlines: JSON.stringify(rawQuestlines ?? []),
updated_at: new Date().toISOString(),
})
.where("quest_proposal.row_id", "=", row.row_id)
.execute();
return success(`Updated quest proposal PR #${row.github_pr_number}.`, {
proposalID: row.row_id,
prNumber: row.github_pr_number,
prUrl: `https://github.com/${cfg.REPO_OWNER}/${cfg.REPO_NAME}/pull/${row.github_pr_number}`,
});
});
// ─── DELETE /proposals/:proposalID — Withdraw a proposal ──────────────────────
/**
* Close (withdraw) a quest proposal. Closes the GitHub PR and marks the row as
* closed. Only the submitter or an admin may withdraw.
*
* @name DELETE /api/v1/proposals/:proposalID
*/
API_V1_ROUTER.add("DELETE /proposals/:proposalID", async ({ req, params }) => {
requireGitHubConfig();
const sessionUser = requireSession(req);
const cfg = ServerConfig.GITHUB_APP_CONFIG!;
const row = await DB.selectFrom("quest_proposal")
.innerJoin("account", "account.id", "quest_proposal.user_id")
.select([
"quest_proposal.row_id",
"quest_proposal.github_pr_number",
"quest_proposal.github_branch",
"quest_proposal.status",
"quest_proposal.user_id",
"account.auth_level",
])
.where("quest_proposal.row_id", "=", params.proposalID)
.executeTakeFirst();
if (!row) {
throw new ExpectedErr(404, `No proposal found with ID '${params.proposalID}'.`);
}
const isOwner = row.user_id === sessionUser.id;
const isAdmin = row.auth_level === "admin";
if (!isOwner && !isAdmin) {
throw new ExpectedErr(403, "You can only withdraw your own proposals.");
}
if (row.status !== "open") {
throw new ExpectedErr(409, `Proposal is already ${row.status}.`);
}
// Close the PR on GitHub
try {
const octokit = await getInstallationOctokit();
await octokit.request("PATCH /repos/{owner}/{repo}/pulls/{pull_number}", {
owner: cfg.REPO_OWNER,
repo: cfg.REPO_NAME,
pull_number: row.github_pr_number,
state: "closed",
});
} catch (err) {
log.warn(
{ err, prNumber: row.github_pr_number },
"Failed to close GitHub PR when withdrawing proposal.",
);
}
await DB.updateTable("quest_proposal")
.set({ status: "closed", updated_at: new Date().toISOString() })
.where("quest_proposal.row_id", "=", row.row_id)
.execute();
return success("Proposal withdrawn.", { proposalID: row.row_id });
});
// ─── POST /proposals/webhook/merged — github-bot notification ─────────────────
/**
* Called by the github-bot when a quest-proposal PR is merged.
* Validates a shared secret header and marks the matching proposal as merged.
*
* @name POST /api/v1/proposals/webhook/merged
*/
API_V1_ROUTER.add("POST /proposals/webhook/merged", async ({ req, input }) => {
const cfg = requireGitHubConfig();
const secret = req.headers["x-tachi-webhook-secret"];
if (secret !== cfg.WEBHOOK_SECRET) {
throw new ExpectedErr(401, "Invalid webhook secret.");
}
const { prNumber } = input as { prNumber: number };
if (typeof prNumber !== "number") {
throw new ExpectedErr(400, "prNumber must be a number.");
}
const result = await DB.updateTable("quest_proposal")
.set({ status: "merged", updated_at: new Date().toISOString() })
.where("quest_proposal.github_pr_number", "=", prNumber)
.where("quest_proposal.status", "=", "open")
.returning("quest_proposal.row_id")
.executeTakeFirst();
if (!result) {
// Not necessarily an error — bot may call this for non-proposal PRs too
return success("No matching open proposal for this PR.", { updated: false });
}
log.info({ prNumber, proposalID: result.row_id }, "Quest proposal marked as merged.");
return success("Proposal marked as merged.", { updated: true, proposalID: result.row_id });
});
@@ -23,6 +23,7 @@ await import("./activity/router");
await import("./config/router");
await import("./localdev/router");
await import("./seeds/router");
await import("./proposals/router");
await import("./scores/_scoreID/router");
await import("./users/_userID/router");
@@ -2008,6 +2008,18 @@ export const API_V1_SPEC = {
output: empty,
},
"POST /admin/quest-submitter/:userID": {
description: "Grant quest-submission permission to a user.",
input: z.object({}),
output: empty,
},
"DELETE /admin/quest-submitter/:userID": {
description: "Revoke quest-submission permission from a user.",
input: z.object({}),
output: empty,
},
"POST /admin/rebuild-folder-chart-lookup": {
description: "Rebuild the folder-chart lookup cache.",
input: z.object({ folderId: z.string().optional() }),
@@ -2081,6 +2093,108 @@ export const API_V1_SPEC = {
input: z.object({}),
output: z.strictObject({ username: z.string(), password: z.string() }),
},
// ────────────────────────────────────────────────
// Quest Proposals
// ────────────────────────────────────────────────
"POST /proposals": {
description: "Submit quest(s) as a GitHub PR. Requires GITHUB_APP_CONFIG to be set.",
input: z.object({
quests: z.array(z.record(z.string(), z.unknown())),
questlines: z.array(z.record(z.string(), z.unknown())).optional(),
prTitle: z.string().max(200).optional(),
}),
output: z.strictObject({
proposalID: z.string(),
prNumber: z.number(),
prUrl: z.string(),
status: z.string(),
}),
},
"GET /proposals": {
description: "List all open quest proposals.",
input: z.object({ page: z.coerce.number().int().optional() }),
output: z.strictObject({
proposals: z.array(
z.strictObject({
proposalID: z.string(),
prNumber: z.number(),
prUrl: z.string(),
status: z.string(),
submitterUsername: z.string(),
quests: z.array(z.strictObject({ name: z.string(), game: z.string() })),
createdAt: z.string(),
}),
),
page: z.number(),
}),
},
"GET /proposals/mine": {
description: "List the calling user's quest proposals.",
input: z.object({}),
output: z.strictObject({
proposals: z.array(
z.strictObject({
proposalID: z.string(),
prNumber: z.number(),
prUrl: z.string(),
status: z.string(),
rawQuests: z.unknown(),
rawQuestlines: z.unknown(),
createdAt: z.string(),
updatedAt: z.string(),
}),
),
}),
},
"GET /proposals/:proposalID": {
description: "Get a single quest proposal with live GitHub PR status.",
input: z.object({}),
output: z.strictObject({
proposalID: z.string(),
prNumber: z.number(),
prUrl: z.string(),
status: z.string(),
submitterUsername: z.string(),
rawQuests: z.unknown(),
rawQuestlines: z.unknown(),
createdAt: z.string(),
updatedAt: z.string(),
}),
},
"PUT /proposals/:proposalID": {
description: "Update quest content of an existing proposal (push new commit to branch).",
input: z.object({
quests: z.array(z.record(z.string(), z.unknown())),
questlines: z.array(z.record(z.string(), z.unknown())).optional(),
prTitle: z.string().max(200).optional(),
}),
output: z.strictObject({
proposalID: z.string(),
prNumber: z.number(),
prUrl: z.string(),
}),
},
"DELETE /proposals/:proposalID": {
description: "Withdraw (close) a quest proposal.",
input: z.object({}),
output: z.strictObject({ proposalID: z.string() }),
},
"POST /proposals/webhook/merged": {
description: "Internal webhook called by github-bot when a quest-proposal PR is merged.",
input: z.object({ prNumber: z.number() }),
output: z.strictObject({
updated: z.boolean(),
proposalID: z.string().optional(),
}),
},
} as const satisfies AnyRouterSpec;
export type APIv1Spec = typeof API_V1_SPEC;
@@ -58,27 +58,31 @@ describe("GET /api/v1/users/:userID/games/:game/targets/goals", () => {
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")
.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();
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`);
@@ -187,27 +191,31 @@ describe("GET /api/v1/users/:userID/games/:game/targets/goals/:goalID", () => {
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")
.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();
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}`,
@@ -169,9 +169,7 @@ API_V1_ROUTER.add(
const taker = { acct: { id: sessionUser.id, username: sessionUser.username }, ip: req.ip };
const { ACTION_UpdateGoalSubscription } = await import(
"#actions/update-goal-subscription"
);
const { ACTION_UpdateGoalSubscription } = await import("#actions/update-goal-subscription");
const { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } = await import(
"#utils/db"
);
@@ -229,8 +229,8 @@ describe("PUT /api/v1/users/:userID/games/:game/targets/quests/:questID", () =>
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);
expect(res.body.description).toMatch(/quest/iu);
expect(res.body.description).not.toMatch(/goal/iu);
});
});
+9 -15
View File
@@ -1,8 +1,6 @@
import {
ALL_GAMES,
type ChartDocument,
LEGACY_GameGroupPTToGame,
type LEGACY_Playtype,
type SEEDS_BMSCourseDocument,
type SEEDS_ChartDocument,
type SEEDS_FolderDocument,
@@ -38,21 +36,17 @@ import { type Insertable, type Kysely, type RawBuilder, sql } from "kysely";
import path from "path";
/**
* Maps `goals.json` / `quests.json` / `questlines.json` fields where `game` may be a
* legacy {@link GameGroup} (e.g. "iidx") to the Postgres `game` enum (e.g. "iidx-sp").
* Maps a `game` field from seeds JSON (now always a V3Game, e.g. "iidx-sp") to
* the Postgres `game` enum.
*/
function seedJsonGameToPg(game: string, playtype: string | undefined, label: string): PgGame {
function seedJsonGameToPg(game: string, label: string): PgGame {
if ((ALL_GAMES as readonly string[]).includes(game)) {
return game as PgGame;
}
if (playtype === undefined) {
throw new Error(
`[seeds] ${label}: missing playtype for legacy game group ${JSON.stringify(game)}`,
);
}
return LEGACY_GameGroupPTToGame(game as GameGroup, playtype as LEGACY_Playtype) as PgGame;
throw new Error(
`[seeds] ${label}: unrecognised game ${JSON.stringify(game)}. Expected a V3Game string.`,
);
}
const INSERT_CHUNK = 500;
@@ -553,7 +547,7 @@ export async function importSeeds(pg: Kysely<Database>, seedsDir: string): Promi
const seeds = readSeedFile<SEEDS_GoalDocument>(seedsDir, "goals.json");
const rows: Array<NewGoal> = seeds.map((g) => ({
id: g.goalID,
game: seedJsonGameToPg(g.game, g.playtype, `goal ${g.goalID}`),
game: seedJsonGameToPg(g.game, `goal ${g.goalID}`),
name: g.name,
charts: JSON.stringify(g.charts),
criteria: JSON.stringify(g.criteria),
@@ -579,7 +573,7 @@ export async function importSeeds(pg: Kysely<Database>, seedsDir: string): Promi
const seeds = readSeedFile<SEEDS_QuestDocument>(seedsDir, "quests.json");
const rows: Array<NewQuest> = seeds.map((q) => ({
id: q.questID,
game: seedJsonGameToPg(q.game, q.playtype, `quest ${q.questID}`),
game: seedJsonGameToPg(q.game, `quest ${q.questID}`),
name: q.name,
description: q.desc,
quest_data: JSON.stringify(q.questData),
@@ -608,7 +602,7 @@ export async function importSeeds(pg: Kysely<Database>, seedsDir: string): Promi
const seeds = readSeedFile<SEEDS_QuestlineDocument>(seedsDir, "questlines.json");
const rows: Array<NewQuestline> = seeds.map((ql) => ({
id: ql.questlineID,
game: seedJsonGameToPg(ql.game, ql.playtype, `questline ${ql.questlineID}`),
game: seedJsonGameToPg(ql.game, `questline ${ql.questlineID}`),
name: ql.name,
description: ql.desc,
}));