mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-02 11:58:12 +03:00
dasdatmeespresso
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
|
||||
+70
-59
@@ -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");
|
||||
|
||||
+30
-22
@@ -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;
|
||||
|
||||
+48
-40
@@ -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}`,
|
||||
|
||||
+1
-3
@@ -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"
|
||||
);
|
||||
|
||||
+2
-2
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user