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
+10 -2
View File
@@ -33,8 +33,16 @@ db-migrate-status POSTGRES_DB=DEFAULT_DB:
# Load the seeds into the database.
db-load-seeds POSTGRES_DB=DEFAULT_DB:
cd typescript/server && POSTGRES_URL="{{POSTGRES_URL}}/{{POSTGRES_DB}}" \
bun run src/scripts/load-seeds-pg.ts
#!/bin/bash
set -euo pipefail
cd typescript/server
export POSTGRES_URL="{{POSTGRES_URL}}/{{POSTGRES_DB}}"
export SEEDS_DIR="../../db/seeds"
if [[ -z "${SEEDS_COMMIT_HASH:-}" ]]; then
SEEDS_COMMIT_HASH="$(git rev-parse HEAD 2>/dev/null || echo local)"
fi
export SEEDS_COMMIT_HASH
bun run src/scripts/load-seeds-pg.ts
# Reset templatedb from migrations, then dump schema and indexes for local debugging.
db-schema: (db-cheap-reset "templatedb")
+113
View File
@@ -347,6 +347,7 @@
"version": "3.0.0",
"dependencies": {
"@aws-sdk/client-s3": "catalog:",
"@octokit/app": "^16.1.2",
"@types/bcryptjs": "catalog:",
"@types/bunyan": "catalog:",
"@types/connect-redis": "catalog:",
@@ -2231,6 +2232,8 @@
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="],
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
@@ -3383,6 +3386,8 @@
"superagent/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
"tachi-server/@octokit/app": ["@octokit/app@16.1.2", "", { "dependencies": { "@octokit/auth-app": "^8.1.2", "@octokit/auth-unauthenticated": "^7.0.3", "@octokit/core": "^7.0.6", "@octokit/oauth-app": "^8.0.3", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/types": "^16.0.0", "@octokit/webhooks": "^14.0.0" } }, "sha512-8j7sEpUYVj18dxvh0KWj6W/l6uAiVRBl1JBDVRqH1VHKAO/G5eRVl4yEoYACjakWers1DjUkcCHyJNQK47JqyQ=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"test-exclude/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
@@ -3507,6 +3512,20 @@
"serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"tachi-server/@octokit/app/@octokit/auth-app": ["@octokit/auth-app@8.2.0", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.3", "@octokit/auth-oauth-user": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "toad-cache": "^3.7.0", "universal-github-app-jwt": "^2.2.0", "universal-user-agent": "^7.0.0" } }, "sha512-vVjdtQQwomrZ4V46B9LaCsxsySxGoHsyw6IYBov/TqJVROrlYdyNgw5q6tQbB7KZt53v1l1W53RiqTvpzL907g=="],
"tachi-server/@octokit/app/@octokit/auth-unauthenticated": ["@octokit/auth-unauthenticated@7.0.3", "", { "dependencies": { "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0" } }, "sha512-8Jb1mtUdmBHL7lGmop9mU9ArMRUTRhg8vp0T1VtZ4yd9vEm3zcLwmjQkhNEduKawOOORie61xhtYIhTDN+ZQ3g=="],
"tachi-server/@octokit/app/@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="],
"tachi-server/@octokit/app/@octokit/oauth-app": ["@octokit/oauth-app@8.0.3", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.2", "@octokit/auth-oauth-user": "^6.0.1", "@octokit/auth-unauthenticated": "^7.0.2", "@octokit/core": "^7.0.5", "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/oauth-methods": "^6.0.1", "@types/aws-lambda": "^8.10.83", "universal-user-agent": "^7.0.0" } }, "sha512-jnAjvTsPepyUaMu9e69hYBuozEPgYqP4Z3UnpmvoIzHDpf8EXDGvTY1l1jK0RsZ194oRd+k6Hm13oRU8EoDFwg=="],
"tachi-server/@octokit/app/@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="],
"tachi-server/@octokit/app/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"tachi-server/@octokit/app/@octokit/webhooks": ["@octokit/webhooks@14.2.0", "", { "dependencies": { "@octokit/openapi-webhooks-types": "12.1.0", "@octokit/request-error": "^7.0.0", "@octokit/webhooks-methods": "^6.0.0" } }, "sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw=="],
"test-exclude/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"test-exclude/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
@@ -3553,6 +3572,42 @@
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@9.0.3", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/auth-oauth-user": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg=="],
"tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-user": ["@octokit/auth-oauth-user@6.0.2", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A=="],
"tachi-server/@octokit/app/@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.9", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A=="],
"tachi-server/@octokit/app/@octokit/auth-app/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"tachi-server/@octokit/app/@octokit/auth-unauthenticated/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"tachi-server/@octokit/app/@octokit/core/@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
"tachi-server/@octokit/app/@octokit/core/@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="],
"tachi-server/@octokit/app/@octokit/core/@octokit/request": ["@octokit/request@10.0.9", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A=="],
"tachi-server/@octokit/app/@octokit/core/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"tachi-server/@octokit/app/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@9.0.3", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/auth-oauth-user": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-user": ["@octokit/auth-oauth-user@6.0.2", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@8.0.0", "", {}, "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/oauth-methods": ["@octokit/oauth-methods@6.0.2", "", { "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0" } }, "sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng=="],
"tachi-server/@octokit/app/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"tachi-server/@octokit/app/@octokit/webhooks/@octokit/openapi-webhooks-types": ["@octokit/openapi-webhooks-types@12.1.0", "", {}, "sha512-WiuzhOsiOvb7W3Pvmhf8d2C6qaLHXrWiLBP4nJ/4kydu+wpagV5Fkz9RfQwV2afYzv3PB+3xYgp4mAdNGjDprA=="],
"tachi-server/@octokit/app/@octokit/webhooks/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"tachi-server/@octokit/app/@octokit/webhooks/@octokit/webhooks-methods": ["@octokit/webhooks-methods@6.0.0", "", {}, "sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ=="],
"test-exclude/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"test-exclude/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -3577,6 +3632,64 @@
"rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-app/@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.3", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw=="],
"tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-user/@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.3", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw=="],
"tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-user/@octokit/oauth-methods": ["@octokit/oauth-methods@6.0.2", "", { "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0" } }, "sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng=="],
"tachi-server/@octokit/app/@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"tachi-server/@octokit/app/@octokit/auth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"tachi-server/@octokit/app/@octokit/auth-app/@octokit/request/fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
"tachi-server/@octokit/app/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"tachi-server/@octokit/app/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"tachi-server/@octokit/app/@octokit/core/@octokit/request/fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-app/@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.3", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-app/@octokit/request": ["@octokit/request@10.0.9", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-user/@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.3", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-user/@octokit/request": ["@octokit/request@10.0.9", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/oauth-methods/@octokit/request": ["@octokit/request@10.0.9", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/oauth-methods/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"typescript-eslint/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-app/@octokit/auth-oauth-device/@octokit/oauth-methods": ["@octokit/oauth-methods@6.0.2", "", { "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0" } }, "sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng=="],
"tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-user/@octokit/oauth-methods/@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@8.0.0", "", {}, "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-app/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-app/@octokit/request/fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-user/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-user/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-user/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/auth-oauth-user/@octokit/request/fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/oauth-methods/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/oauth-methods/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"tachi-server/@octokit/app/@octokit/oauth-app/@octokit/oauth-methods/@octokit/request/fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
"tachi-server/@octokit/app/@octokit/auth-app/@octokit/auth-oauth-app/@octokit/auth-oauth-device/@octokit/oauth-methods/@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@8.0.0", "", {}, "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ=="],
}
}
@@ -0,0 +1,14 @@
CREATE TABLE "quest_proposal" (
row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(),
user_id BIGINT REFERENCES account(id) NOT NULL,
github_pr_number INT NOT NULL,
github_branch TEXT NOT NULL,
-- Serialised RawQuestDocument[] (the editor's inline-goal format)
raw_quests JSONB NOT NULL,
-- Serialised RawQuestlineDocument[] (may be empty array)
raw_questlines JSONB NOT NULL DEFAULT '[]',
-- 'open' | 'merged' | 'closed'
status TEXT NOT NULL DEFAULT 'open',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1,5 @@
-- Add a permission flag allowing users to submit quest proposals to the repo.
-- Users must apply (via Discord) and be approved by an admin before they can
-- open PRs via the quest editor.
ALTER TABLE account ADD COLUMN can_submit_quests BOOLEAN NOT NULL DEFAULT FALSE;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+573 -716
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -1,9 +1,8 @@
[
{
"desc": "These are the official quests for IIDX SP. These aim to be generally useful, and don't focus too much on specific skills.",
"game": "iidx",
"game": "iidx-sp",
"name": "Kamaitachi Official IIDX SP Questline",
"playtype": "SP",
"questlineID": "kt-official-iidx-SP",
"quests": [
"7f01dd4bb78537a37f0d06456c9153f960b1ab1c",
@@ -20,9 +19,8 @@
},
{
"desc": "These are the official quests for SDVX. These aim to be generally useful, and don't focus too much on specific skills. These were primarily created by Okami and Winter.",
"game": "sdvx",
"game": "sdvx-single",
"name": "Kamaitachi Official SDVX Questline",
"playtype": "Single",
"questlineID": "kt-official-sdvx",
"quests": [
"559ab57794351e6eab54f2aaec0e27d52c6b3c49",
+19 -38
View File
@@ -1,9 +1,8 @@
[
{
"desc": "These goals are around 13.1 KtLampRating and 5-10BPI.",
"game": "iidx",
"game": "iidx-sp",
"name": "Post-Kaiden II",
"playtype": "SP",
"questData": [
{
"title": "Specific Clear Goals",
@@ -86,9 +85,8 @@
},
{
"desc": "Good luck, it's only harder from here on. On the bright side, you don't have to play dans anymore. These goals are around 13.0 KtLampRating and 0BPI.",
"game": "iidx",
"game": "iidx-sp",
"name": "Post-Kaiden I",
"playtype": "SP",
"questData": [
{
"title": "Specific Clear Goals",
@@ -171,9 +169,8 @@
},
{
"desc": "These goals are around 13.2 KtLampRating and 10-20BPI.",
"game": "iidx",
"game": "iidx-sp",
"name": "Post-Kaiden III",
"playtype": "SP",
"questData": [
{
"title": "Specific Clear Goals",
@@ -264,9 +261,8 @@
},
{
"desc": "These goals are around 13.3 KtLampRating and 20-30BPI.",
"game": "iidx",
"game": "iidx-sp",
"name": "Post-Kaiden IV",
"playtype": "SP",
"questData": [
{
"title": "Specific Clear Goals",
@@ -360,9 +356,8 @@
},
{
"desc": "This is the end of the line, for now. Good luck.",
"game": "iidx",
"game": "iidx-sp",
"name": "Post-Kaiden VI",
"playtype": "SP",
"questData": [
{
"title": "Specific Clear Goals",
@@ -433,9 +428,8 @@
},
{
"desc": "These goals are around 13.5 KtLampRating and 30-50BPI.",
"game": "iidx",
"game": "iidx-sp",
"name": "Post-Kaiden V",
"playtype": "SP",
"questData": [
{
"title": "Specific Clear Goals",
@@ -498,9 +492,8 @@
},
{
"desc": "These goals are aimed at players roughly between Chuuden and Kaiden.",
"game": "iidx",
"game": "iidx-sp",
"name": "Approaching Kaiden",
"playtype": "SP",
"questData": [
{
"title": "Specific Clear Goals",
@@ -595,9 +588,8 @@
},
{
"desc": "These goals are aimed at players roughly between 10th Dan and Chuuden.",
"game": "iidx",
"game": "iidx-sp",
"name": "Approaching Chuuden",
"playtype": "SP",
"questData": [
{
"title": "Specific Clear Goals",
@@ -672,9 +664,8 @@
},
{
"desc": "These goals are aimed at players roughly between 9th and 10th Dan.",
"game": "iidx",
"game": "iidx-sp",
"name": "Approaching 10th Dan",
"playtype": "SP",
"questData": [
{
"title": "Specific Clear Goals",
@@ -749,9 +740,8 @@
},
{
"desc": "These goals are aimed at players roughly between 8th and 9th Dan. Simultaneously, you'll be breaking into 12s around this time. Good luck!",
"game": "iidx",
"game": "iidx-sp",
"name": "Approaching 9th Dan",
"playtype": "SP",
"questData": [
{
"title": "Specific Clear Goals",
@@ -824,9 +814,8 @@
},
{
"desc": "These goals are aimed at players roughly between 13-14 VF.",
"game": "sdvx",
"game": "sdvx-single",
"name": "Dandelion (15s-16s)",
"playtype": "Single",
"questData": [
{
"title": "Timing Goals",
@@ -881,9 +870,8 @@
},
{
"desc": "These goals are aimed at players roughly between 14-15 VF.",
"game": "sdvx",
"game": "sdvx-single",
"name": "Cyan (16s)",
"playtype": "Single",
"questData": [
{
"title": "Timing Goals",
@@ -938,9 +926,8 @@
},
{
"desc": "These goals are aimed at players roughly between 15-16 VF.",
"game": "sdvx",
"game": "sdvx-single",
"name": "Scarlet (17s)",
"playtype": "Single",
"questData": [
{
"title": "Timing Goals",
@@ -994,9 +981,8 @@
},
{
"desc": "These goals are aimed at players roughly between 16-17 VF.",
"game": "sdvx",
"game": "sdvx-single",
"name": "Coral (Early 18s)",
"playtype": "Single",
"questData": [
{
"title": "Timing Goals",
@@ -1051,9 +1037,8 @@
},
{
"desc": "These goals are aimed at players roughly between 17-18 VF.",
"game": "sdvx",
"game": "sdvx-single",
"name": "Argento (18s - Early 19s)",
"playtype": "Single",
"questData": [
{
"title": "Timing Goals",
@@ -1112,9 +1097,8 @@
},
{
"desc": "These goals are aimed at players roughly between 18-18.5 VF.",
"game": "sdvx",
"game": "sdvx-single",
"name": "Eldora A (Mid-High 19s)",
"playtype": "Single",
"questData": [
{
"title": "Timing Goals",
@@ -1173,9 +1157,8 @@
},
{
"desc": "These goals are aimed at players roughly between 18.5-19 VF.",
"game": "sdvx",
"game": "sdvx-single",
"name": "Eldora B (Early 20s)",
"playtype": "Single",
"questData": [
{
"title": "Timing Goals",
@@ -1231,9 +1214,8 @@
},
{
"desc": "These goals are aimed at players roughly between 19-19.5 VF.",
"game": "sdvx",
"game": "sdvx-single",
"name": "Crimson A (20s)",
"playtype": "Single",
"questData": [
{
"title": "Timing Goals",
@@ -1287,9 +1269,8 @@
},
{
"desc": "This is the end of the road, for now.",
"game": "sdvx",
"game": "sdvx-single",
"name": "Crimson B (Post-20s)",
"playtype": "Single",
"questData": [
{
"title": "Timing Goals",
+1 -1
View File
@@ -177,7 +177,7 @@ See previous example.
This endpoint goes through all of the requesting user's [Orphanned Scores](../../codebase/import/orphans.md) and attempts to find them a parent song & chart.
!!! note
Scores automatically attempt de-orphaning every day on Kamaitachi and Bokutachi,
Scores automatically attempt de-orphaning every day at 1 AM UTC on Kamaitachi and Bokutachi,
this endpoint just allows you to force a deorphaning, should you wish to.
### Permissions
+3 -1
View File
@@ -137,7 +137,9 @@ async function GetServerConfig() {
}
if (!parsed || typeof parsed !== "object" || !("success" in parsed)) {
log.error(`Server config response from ${url} was JSON but missing a success field. Can't run.`);
log.error(
`Server config response from ${url} was JSON but missing a success field. Can't run.`,
);
process.exit(1);
}
+1
View File
@@ -38,6 +38,7 @@ globalThis.fetch = (url: string | Request | URL): Promise<Response> => {
NAME: "Test Tachi",
TYPE: "boku",
SIGNUPS_ENABLED: true,
QUEST_PROPOSALS_ENABLED: false,
GAME_GROUPS: ["iidx"],
IMPORT_TYPES: [...allImportTypes],
},
@@ -0,0 +1,220 @@
/**
* MyProposalsPage — shows the logged-in user's own quest proposals,
* with edit and withdraw capabilities.
*/
import type { UnsuccessfulAPIResponse } from "tachi-common";
import useSetSubheader from "#components/layout/header/useSetSubheader";
import LoadingWrapper from "#components/util/LoadingWrapper";
import { UserContext } from "#context/UserContext";
import { TachiConfig } from "#lib/config";
import { APIFetchV1 } from "#util/api";
import React, { useCallback, useContext, useEffect, useState } from "react";
import { Alert, Badge, Button, Card, Col, Modal, Row } from "react-bootstrap";
import { Link, Redirect } from "react-router-dom";
type MyProposal = {
createdAt: string;
prNumber: number;
proposalID: string;
prUrl: string;
rawQuestlines: unknown;
rawQuests: unknown;
status: string;
updatedAt: string;
};
type MyProposalsResponse = {
proposals: Array<MyProposal>;
};
function statusBadge(status: string) {
switch (status) {
case "open":
return <Badge bg="success">Open</Badge>;
case "merged":
return <Badge bg="primary">Merged</Badge>;
case "closed":
return <Badge bg="secondary">Closed</Badge>;
default:
return <Badge bg="secondary">{status}</Badge>;
}
}
export default function MyProposalsPage() {
useSetSubheader(["My Quest Proposals"]);
const { user } = useContext(UserContext);
if (!user) {
return <Redirect to="/login" />;
}
const [data, setData] = useState<MyProposalsResponse | null>(null);
const [error, setError] = useState<UnsuccessfulAPIResponse | null>(null);
const reload = useCallback(() => {
setData(null);
setError(null);
APIFetchV1<MyProposalsResponse>("/proposals/mine").then((res) => {
if (!res.success) {
setError(res);
return;
}
setData(res.body);
});
}, []);
useEffect(() => {
reload();
}, [reload]);
return (
<>
<div className="d-flex align-items-center justify-content-between mb-3">
<h2 className="mb-0">My Quest Proposals</h2>
{TachiConfig.QUEST_PROPOSALS_ENABLED && (
<Link className="btn btn-outline-success btn-sm" to="/quests">
+ New Proposal
</Link>
)}
</div>
<LoadingWrapper dataset={data} error={error}>
<>
{data?.proposals.length === 0 ? (
<p className="text-body-secondary">
You haven&apos;t submitted any proposals yet.
{TachiConfig.QUEST_PROPOSALS_ENABLED && (
<>
{" "}
<Link to="/quests">Create one in the Quest Editor!</Link>
</>
)}
</p>
) : (
<Row className="g-3">
{data?.proposals.map((proposal) => (
<Col key={proposal.proposalID} lg={6} xs={12}>
<MyProposalCard onWithdrawn={reload} proposal={proposal} />
</Col>
))}
</Row>
)}
</>
</LoadingWrapper>
</>
);
}
function MyProposalCard({
proposal,
onWithdrawn,
}: {
onWithdrawn: () => void;
proposal: MyProposal;
}) {
const [withdrawing, setWithdrawing] = useState(false);
const [withdrawErr, setWithdrawErr] = useState<string | null>(null);
const [showConfirm, setShowConfirm] = useState(false);
const questNames = Array.isArray(proposal.rawQuests)
? (proposal.rawQuests as Array<{ name: string }>).map((q) => q.name).join(", ")
: "Quest Proposal";
const handleWithdraw = async () => {
setWithdrawErr(null);
setWithdrawing(true);
try {
const res = await APIFetchV1(`/proposals/${proposal.proposalID}`, {
method: "DELETE",
});
if (!res.success) {
setWithdrawErr(res.description);
return;
}
setShowConfirm(false);
onWithdrawn();
} catch (e) {
setWithdrawErr((e as Error).message);
} finally {
setWithdrawing(false);
}
};
return (
<>
<Card className="h-100">
<Card.Body>
<div className="d-flex align-items-center justify-content-between mb-2">
<span className="text-body-secondary small">PR #{proposal.prNumber}</span>
{statusBadge(proposal.status)}
</div>
<h6 className="mb-1">{questNames}</h6>
<p className="text-body-secondary small mb-2">
Submitted {new Date(proposal.createdAt).toLocaleDateString()}
{proposal.updatedAt !== proposal.createdAt && (
<>
{" · "}Updated {new Date(proposal.updatedAt).toLocaleDateString()}
</>
)}
</p>
<div className="d-flex gap-2 flex-wrap">
<a
className="btn btn-outline-primary btn-sm"
href={proposal.prUrl}
rel="noopener noreferrer"
target="_blank"
>
View on GitHub
</a>
{proposal.status === "open" && (
<Button
onClick={() => setShowConfirm(true)}
size="sm"
variant="outline-danger"
>
Withdraw
</Button>
)}
</div>
</Card.Body>
</Card>
<Modal onHide={() => setShowConfirm(false)} show={showConfirm}>
<Modal.Header closeButton>
<Modal.Title>Withdraw Proposal?</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
This will close PR #{proposal.prNumber} and mark the proposal as withdrawn.
This cannot be undone.
</p>
{withdrawErr && <Alert variant="danger">{withdrawErr}</Alert>}
</Modal.Body>
<Modal.Footer>
<Button
disabled={withdrawing}
onClick={() => setShowConfirm(false)}
variant="secondary"
>
Cancel
</Button>
<Button disabled={withdrawing} onClick={handleWithdraw} variant="danger">
{withdrawing ? "Withdrawing…" : "Withdraw"}
</Button>
</Modal.Footer>
</Modal>
</>
);
}
@@ -0,0 +1,153 @@
/**
* ProposalsPage — public listing of all open quest proposals.
*/
import type { UnsuccessfulAPIResponse } from "tachi-common";
import useSetSubheader from "#components/layout/header/useSetSubheader";
import LoadingWrapper from "#components/util/LoadingWrapper";
import { TachiConfig } from "#lib/config";
import { APIFetchV1 } from "#util/api";
import React, { useEffect, useState } from "react";
import { Badge, Card, Col, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
type ProposalSummary = {
createdAt: string;
prNumber: number;
proposalID: string;
prUrl: string;
quests: Array<{ game: string; name: string }>;
status: string;
submitterUsername: string;
};
type ProposalsResponse = {
page: number;
proposals: Array<ProposalSummary>;
};
function statusBadge(status: string) {
switch (status) {
case "open":
return <Badge bg="success">Open</Badge>;
case "merged":
return <Badge bg="primary">Merged</Badge>;
case "closed":
return <Badge bg="secondary">Closed</Badge>;
default:
return <Badge bg="secondary">{status}</Badge>;
}
}
export default function ProposalsPage() {
useSetSubheader(["Community", "Quest Proposals"]);
const [data, setData] = useState<ProposalsResponse | null>(null);
const [error, setError] = useState<UnsuccessfulAPIResponse | null>(null);
const [page, setPage] = useState(0);
useEffect(() => {
setData(null);
setError(null);
APIFetchV1<ProposalsResponse>(`/proposals?page=${page}`).then((res) => {
if (!res.success) {
setError(res);
return;
}
setData(res.body);
});
}, [page]);
return (
<>
<h2 className="mb-1">Community Quest Proposals</h2>
<p className="text-body-secondary small mb-3">
Quest proposals submitted by the community for review.
{TachiConfig.QUEST_PROPOSALS_ENABLED ? (
<>
{" "}
Open a PR to add your own quests via the <Link to="/quests">Quest Editor</Link>.
</>
) : (
<> Open a PR on the seeds repository to add your own quests.</>
)}
</p>
<LoadingWrapper dataset={data} error={error}>
<>
{data?.proposals.length === 0 && page === 0 ? (
<p className="text-body-secondary">No open proposals yet. Be the first!</p>
) : (
<Row className="g-3">
{data?.proposals.map((p) => (
<Col key={p.proposalID} lg={4} md={6} xs={12}>
<ProposalCard proposal={p} />
</Col>
))}
</Row>
)}
<div className="d-flex gap-2 mt-4">
{page > 0 && (
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => setPage((prev) => prev - 1)}
type="button"
>
← Previous
</button>
)}
{data && data.proposals.length === 20 && (
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => setPage((prev) => prev + 1)}
type="button"
>
Next →
</button>
)}
</div>
</>
</LoadingWrapper>
</>
);
}
function ProposalCard({ proposal }: { proposal: ProposalSummary }) {
return (
<Card className="h-100">
<Card.Body>
<div className="d-flex align-items-center justify-content-between mb-2">
<span className="text-body-secondary small">PR #{proposal.prNumber}</span>
{statusBadge(proposal.status)}
</div>
<h6 className="mb-1">{proposal.quests.map((q) => q.name).join(", ")}</h6>
<p className="text-body-secondary small mb-2">
{proposal.quests.map((q) => q.game).join(", ")}
</p>
<p className="text-body-secondary small mb-3">
Submitted by{" "}
<Link to={`/u/${proposal.submitterUsername}`}>
{proposal.submitterUsername}
</Link>{" "}
on {new Date(proposal.createdAt).toLocaleDateString()}
</p>
<a
className="btn btn-outline-primary btn-sm"
href={proposal.prUrl}
rel="noopener noreferrer"
target="_blank"
>
View PR on GitHub
</a>
</Card.Body>
</Card>
);
}
@@ -164,7 +164,7 @@ export default function UserOrphansPage({ reqUser }: { reqUser: UserDocument })
<p className="mb-2">
When an import cannot match a song or chart (SongOrChartNotFound), Tachi still
stores that datapoint as an <strong>orphan</strong>. Orphans are retried
automatically around <strong>00:01 UTC</strong> each day, or you can run a full
automatically around <strong>1 AM UTC</strong> each day, or you can run a full
reprocess below.
</p>
<p className="mb-0">
@@ -1,5 +1,5 @@
import GoalBuilder from "#components/targets/GoalBuilder";
import DeleteGoalsModal from "#components/targets/DeleteGoalsModal";
import GoalBuilder from "#components/targets/GoalBuilder";
import GoalSubInfo from "#components/targets/GoalSubInfo";
import ApiError from "#components/util/ApiError";
import Divider from "#components/util/Divider";
@@ -14,7 +14,7 @@ import { CreateGoalSubDataset } from "#util/data";
import React, { useContext, useReducer, useState } from "react";
import { Button, Col, Modal } from "react-bootstrap";
import { Link } from "react-router-dom";
import { type GoalDocument, FormatGame } from "tachi-common";
import { FormatGame, type GoalDocument } from "tachi-common";
export default function UGPTGoalsPage({ reqUser, game }: UGPT) {
const [showAdd, setShowAdd] = useState(false);
@@ -40,7 +40,9 @@ export default function UGPTGoalsPage({ reqUser, game }: UGPT) {
const userMap = new Map([[reqUser.id, reqUser]]);
const dataset = CreateGoalSubDataset(data, userMap);
const handleAddGoal = async (rawGoal: { goal: Pick<GoalDocument, "charts" | "criteria" | "name"> }) => {
const handleAddGoal = async (rawGoal: {
goal: Pick<GoalDocument, "charts" | "criteria" | "name">;
}) => {
await APIFetchV1(
`/users/${reqUser.id}/games/${game}/targets/goals/add-goal`,
{
@@ -120,11 +122,7 @@ export default function UGPTGoalsPage({ reqUser, game }: UGPT) {
<Modal.Title>Add New Goal</Modal.Title>
</Modal.Header>
<Modal.Body>
<GoalBuilder
confirmLabel="Add Goal"
game={game}
onCreate={handleAddGoal}
/>
<GoalBuilder confirmLabel="Add Goal" game={game} onCreate={handleAddGoal} />
</Modal.Body>
</Modal>
@@ -15,17 +15,19 @@ import EditableQuest from "#components/targets/quests/editor/EditableQuest";
import Divider from "#components/util/Divider";
import EditableText from "#components/util/EditableText";
import Icon from "#components/util/Icon";
import Muted from "#components/util/Muted";
import { UserContext } from "#context/UserContext";
import { TachiConfig } from "#lib/config";
import { type RawQuestDocument, type RawQuestlineDocument } from "#types/tachi";
import { APIFetchV1 } from "#util/api";
import { ChangeAtPosition, DeleteInPosition } from "#util/misc";
import { p, type PrudenceSchema } from "prudence";
import React, { useEffect, useMemo, useState } from "react";
import { Alert, Badge, Button, Col, Form, Modal, Row } from "react-bootstrap";
import React, { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Alert, Badge, Button, Col, Form, Modal, Row, Spinner } from "react-bootstrap";
import {
ALL_GAMES,
FormatGame,
type GameGroup,
FormatPrError,
type GameGroup,
GetGameGroupConfig,
LEGACY_GameGroupPTToGame,
type LEGACY_GPTString,
@@ -43,16 +45,8 @@ const LOCAL_QUESTLINE_KEY = "LOCAL_QUESTLINES";
const PR_LOCAL_QUESTS_SCHEMA: PrudenceSchema = {
json: [
{
game: p.isIn(TachiConfig.GAME_GROUPS),
playtype: (self, parent) => {
const gameConfig = GetGameGroupConfig(parent.game as GameGroup);
if (!(gameConfig.playtypes as ReadonlyArray<unknown>).includes(self)) {
return `Invalid playtype '${String(self)}' for ${String(parent.game)}`;
}
return true;
},
/** V3 game id (e.g. `iidx-sp`), matching {@link RawQuestDocument}. */
game: p.isIn(ALL_GAMES),
name: "string",
desc: "string",
rawQuestData: [
@@ -140,8 +134,25 @@ function downloadJson(data: unknown, filename: string) {
// ─── Main component ───────────────────────────────────────────────────────────
// ─── Proposal types (mirrors server API response) ────────────────────────────
type MyProposal = {
createdAt: string;
prNumber: number;
proposalID: string;
prUrl: string;
rawQuestlines: Array<RawQuestlineDocument>;
rawQuests: Array<RawQuestDocument>;
status: string;
updatedAt: string;
};
// ─── Main component ───────────────────────────────────────────────────────────
export default function QuestEditor() {
useSetSubheader(["Developer Utils", "Quest & Questline Editor"]);
useSetSubheader(["Quest Editor"]);
const { user } = useContext(UserContext);
const initQuests = useMemo(() => loadLocalQuests(), []);
const initQuestlines = useMemo(() => loadLocalQuestlines(), []);
@@ -152,6 +163,35 @@ export default function QuestEditor() {
const [showImportQuests, setShowImportQuests] = useState(false);
const [showImportQuestlines, setShowImportQuestlines] = useState(false);
const [showSubmitModal, setShowSubmitModal] = useState(false);
// When non-null, we're updating an existing proposal rather than creating one
const [editingProposal, setEditingProposal] = useState<MyProposal | null>(null);
// My proposals list
const [proposals, setProposals] = useState<Array<MyProposal> | null>(null);
const [proposalsLoading, setProposalsLoading] = useState(false);
// ── Fetch my proposals ─────────────────────────────────────────────────────
const fetchProposals = useCallback(() => {
if (!user) {
return;
}
setProposalsLoading(true);
APIFetchV1<{ proposals: Array<MyProposal> }>("/proposals/mine").then((res) => {
setProposalsLoading(false);
if (res.success) {
setProposals(res.body.proposals);
}
});
}, [user]);
useEffect(() => {
fetchProposals();
}, [fetchProposals]);
// ── Persist to localStorage on every change ────────────────────────────────
useEffect(() => {
@@ -163,7 +203,7 @@ export default function QuestEditor() {
}, [questlines]);
// ── Derived ────────────────────────────────────────────────────────────────
const selectedQuest = selectedQuestIdx !== null ? quests[selectedQuestIdx] ?? null : null;
const selectedQuest = selectedQuestIdx !== null ? (quests[selectedQuestIdx] ?? null) : null;
const addQuest = (gptString: LEGACY_GPTString) => {
const [game, playtype] = gptString.split(":") as [GameGroup, LEGACY_Playtype];
@@ -183,14 +223,81 @@ export default function QuestEditor() {
});
};
const loadProposalIntoEditor = (proposal: MyProposal) => {
if (quests.length > 0 || questlines.length > 0) {
if (
!confirm(
"This will replace your current editor contents with the proposal's quests. Continue?",
)
) {
return;
}
}
setQuests(proposal.rawQuests);
setQuestlines(proposal.rawQuestlines);
setSelectedQuestIdx(null);
setEditingProposal(proposal);
};
const clearEditingProposal = () => {
setEditingProposal(null);
};
const handleAfterSubmit = () => {
setEditingProposal(null);
fetchProposals();
};
return (
<Row className="g-3">
<Col xs={12}>
<h2 className="mb-1">Quest &amp; Questline Editor</h2>
<p className="text-body-secondary small">
Build quests and questlines locally, then download the JSON files and submit
them as a PR to be included on the site.
</p>
<div className="d-flex align-items-start justify-content-between flex-wrap gap-2">
<div>
<h2 className="mb-1">Quest &amp; Questline Editor</h2>
<p className="text-body-secondary small mb-0">
Build quests locally and submit them to the community.
</p>
</div>
{user && quests.length > 0 && (
user.canSubmitQuests ? (
<Button onClick={() => setShowSubmitModal(true)} variant="success">
<Icon type="code-branch" />{" "}
{editingProposal
? `Update PR #${editingProposal.prNumber}`
: "Submit to Community"}
</Button>
) : (
<span className="text-body-secondary small text-end" style={{ maxWidth: "260px" }}>
<Icon type="lock" />{" "}
Want to submit quests to the community? Ask an admin to grant you
quest-submitter access.
</span>
)
)}
</div>
{/* Editing-proposal banner */}
{editingProposal && (
<Alert className="mt-3 mb-0 d-flex align-items-center justify-content-between" variant="info">
<span>
<Icon type="pencil" />{" "}
Editing{" "}
<a
href={editingProposal.prUrl}
rel="noopener noreferrer"
target="_blank"
>
PR #{editingProposal.prNumber}
</a>
{" — "}make your changes, then click <strong>Update PR</strong>.
</span>
<Button onClick={clearEditingProposal} size="sm" variant="outline-light">
Stop editing
</Button>
</Alert>
)}
<Divider />
</Col>
@@ -233,6 +340,7 @@ export default function QuestEditor() {
if (confirm("Delete all quests and start over?")) {
setQuests([]);
setSelectedQuestIdx(null);
setEditingProposal(null);
}
}}
size="sm"
@@ -276,10 +384,10 @@ export default function QuestEditor() {
<QuestlineComposer
onAddQuestline={(ql) => setQuestlines((prev) => [...prev, ql])}
onDelete={(idx) => setQuestlines(DeleteInPosition(questlines, idx))}
onUpdate={(updated, idx) =>
setQuestlines(ChangeAtPosition(questlines, updated, idx))
}
onDelete={(idx) => setQuestlines(DeleteInPosition(questlines, idx))}
questlines={questlines}
quests={quests}
/>
@@ -321,6 +429,20 @@ export default function QuestEditor() {
</div>
</Col>
{/* ── My Proposals panel ────────────────────────────────────────── */}
{user && (
<Col xs={12}>
<Divider />
<MyProposalsPanel
editingProposalID={editingProposal?.proposalID ?? null}
loading={proposalsLoading}
onLoad={loadProposalIntoEditor}
onWithdrawn={fetchProposals}
proposals={proposals}
/>
</Col>
)}
{/* Import modals */}
{showImportQuests && (
<ImportJsonModal
@@ -340,10 +462,179 @@ export default function QuestEditor() {
onImport={(data) => setQuestlines(data as RawQuestlineDocument[])}
/>
)}
{showSubmitModal && (
<SubmitProposalModal
editingProposal={editingProposal}
onAfterSubmit={handleAfterSubmit}
onHide={() => setShowSubmitModal(false)}
questlines={questlines}
quests={quests}
/>
)}
</Row>
);
}
// ─── MyProposalsPanel ─────────────────────────────────────────────────────────
function statusBadge(status: string) {
switch (status) {
case "open":
return <Badge bg="success">Open</Badge>;
case "merged":
return <Badge bg="primary">Merged</Badge>;
case "closed":
return <Badge bg="secondary">Closed</Badge>;
default:
return <Badge bg="secondary">{status}</Badge>;
}
}
function MyProposalsPanel({
proposals,
loading,
editingProposalID,
onLoad,
onWithdrawn,
}: {
editingProposalID: string | null;
loading: boolean;
onLoad: (p: MyProposal) => void;
onWithdrawn: () => void;
proposals: Array<MyProposal> | null;
}) {
const [withdrawingID, setWithdrawingID] = useState<string | null>(null);
const handleWithdraw = async (proposal: MyProposal) => {
if (!confirm(`Withdraw PR #${proposal.prNumber}? This will close it on GitHub.`)) {
return;
}
setWithdrawingID(proposal.proposalID);
try {
const res = await APIFetchV1(`/proposals/${proposal.proposalID}`, {
method: "DELETE",
});
if (!res.success) {
alert(`Failed to withdraw: ${res.description}`);
} else {
onWithdrawn();
}
} finally {
setWithdrawingID(null);
}
};
return (
<div>
<div className="d-flex align-items-center justify-content-between mb-2">
<h5 className="mb-0">My Proposals</h5>
{loading && <Spinner animation="border" size="sm" />}
</div>
{proposals === null && !loading && (
<p className="text-body-secondary small">
Could not load proposals — are proposals enabled on this instance?
</p>
)}
{proposals !== null && proposals.length === 0 && (
<p className="text-body-secondary small">
No proposals yet. Build some quests and click{" "}
<strong>Submit to Community</strong>!
</p>
)}
{proposals !== null && proposals.length > 0 && (
<div className="d-flex flex-wrap gap-3">
{proposals.map((proposal) => {
const questNames = proposal.rawQuests.map((q) => q.name).join(", ");
const isEditing = editingProposalID === proposal.proposalID;
return (
<div
className={`border rounded p-3 ${isEditing ? "border-info" : ""}`}
key={proposal.proposalID}
style={{ minWidth: "260px", maxWidth: "340px" }}
>
<div className="d-flex align-items-start justify-content-between mb-1">
<span className="text-body-secondary small">
PR #{proposal.prNumber}
</span>
{statusBadge(proposal.status)}
</div>
<p className="mb-1 fw-semibold" style={{ fontSize: "0.9rem" }}>
{questNames}
</p>
<p className="text-body-secondary mb-2" style={{ fontSize: "0.75rem" }}>
{proposal.rawQuests[0]
? FormatGame(proposal.rawQuests[0].game as V3Game)
: ""}{" "}
· {new Date(proposal.createdAt).toLocaleDateString()}
{proposal.updatedAt !== proposal.createdAt && (
<>
{" · "}updated{" "}
{new Date(proposal.updatedAt).toLocaleDateString()}
</>
)}
</p>
<div className="d-flex gap-2 flex-wrap">
<a
className="btn btn-outline-secondary btn-sm py-0"
href={proposal.prUrl}
rel="noopener noreferrer"
target="_blank"
>
GitHub
</a>
{proposal.status === "open" && (
<>
{isEditing ? (
<span className="btn btn-info btn-sm py-0 disabled">
<Icon type="pencil" /> Editing…
</span>
) : (
<Button
onClick={() => onLoad(proposal)}
size="sm"
style={{ padding: "0 0.5rem" }}
variant="outline-primary"
>
<Icon type="pencil" /> Edit
</Button>
)}
<Button
disabled={withdrawingID === proposal.proposalID}
onClick={() => handleWithdraw(proposal)}
size="sm"
style={{ padding: "0 0.5rem" }}
variant="outline-danger"
>
{withdrawingID === proposal.proposalID ? (
<Spinner animation="border" size="sm" />
) : (
"Withdraw"
)}
</Button>
</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
// ─── QuestList ────────────────────────────────────────────────────────────────
function QuestList({
@@ -359,16 +650,15 @@ function QuestList({
}) {
const [gpt, setGpt] = useState<LEGACY_GPTString | null>(null);
const allGpts: Array<{ label: string; value: LEGACY_GPTString }> = TachiConfig.GAME_GROUPS.flatMap(
(gameGroup) => {
const allGpts: Array<{ label: string; value: LEGACY_GPTString }> =
TachiConfig.GAME_GROUPS.flatMap((gameGroup) => {
const config = GetGameGroupConfig(gameGroup);
return config.playtypes.map((pt) => ({
value: `${gameGroup}:${pt}` as LEGACY_GPTString,
label: FormatGame(LEGACY_GameGroupPTToGame(gameGroup, pt)),
}));
},
);
});
return (
<div className="d-flex flex-column gap-1 mb-3">
@@ -378,18 +668,14 @@ function QuestList({
{quests.map((quest, i) => (
<button
className={`btn btn-sm text-start text-truncate ${
selectedIdx === i
? "btn-primary"
: "btn-outline-secondary"
selectedIdx === i ? "btn-primary" : "btn-outline-secondary"
}`}
key={i}
onClick={() => onSelect(i)}
title={quest.name}
type="button"
>
<span className="small me-2 text-body-secondary">
{FormatGame(quest.game)}
</span>
<span className="small me-2 text-body-secondary">{FormatGame(quest.game)}</span>
{quest.name}
</button>
))}
@@ -442,18 +728,17 @@ function QuestlineComposer({
quests: Array<RawQuestDocument>;
}) {
const [newName, setNewName] = useState("");
const [newGame, setNewGame] = useState<LEGACY_GPTString | "">("");
const [newGame, setNewGame] = useState<"" | LEGACY_GPTString>("");
const allGpts: Array<{ label: string; value: LEGACY_GPTString }> = TachiConfig.GAME_GROUPS.flatMap(
(gameGroup) => {
const allGpts: Array<{ label: string; value: LEGACY_GPTString }> =
TachiConfig.GAME_GROUPS.flatMap((gameGroup) => {
const config = GetGameGroupConfig(gameGroup);
return config.playtypes.map((pt) => ({
value: `${gameGroup}:${pt}` as LEGACY_GPTString,
label: FormatGame(LEGACY_GameGroupPTToGame(gameGroup, pt)),
}));
},
);
});
return (
<div className="d-flex flex-column gap-3 mb-3">
@@ -466,8 +751,8 @@ function QuestlineComposer({
key={idx}
onDelete={() => onDelete(idx)}
onUpdate={(updated) => onUpdate(updated, idx)}
quests={quests}
questline={ql}
quests={quests}
/>
))}
@@ -507,6 +792,8 @@ function QuestlineComposer({
LEGACY_Playtype,
];
const v3Game = LEGACY_GameGroupPTToGame(gameGroup, playtype);
const slug = newName
.trim()
.toLowerCase()
@@ -517,8 +804,7 @@ function QuestlineComposer({
questlineID: `${slug}-${Date.now()}`,
name: newName.trim(),
desc: "",
game: gameGroup,
playtype,
game: v3Game,
quests: [],
});
@@ -547,9 +833,7 @@ function QuestlineCard({
questline: RawQuestlineDocument;
quests: Array<RawQuestDocument>;
}) {
const availableQuests = quests.filter(
(q) => !questline.quests.includes(q.name),
);
const availableQuests = quests.filter((q) => !questline.quests.includes(q.name));
const addQuestByName = (name: string) => {
if (!questline.quests.includes(name)) {
@@ -577,20 +861,22 @@ function QuestlineCard({
<div className="border rounded p-2">
<div className="d-flex align-items-start gap-2 mb-1">
<div className="flex-grow-1">
<EditableText
as="span"
authorised
initialText={questline.name}
onSubmit={(name) => onUpdate({ ...questline, name })}
placeholderText="Questline name"
/>
<EditableText
as="span"
authorised
initialText={questline.name}
onSubmit={(name) => onUpdate({ ...questline, name })}
placeholderText="Questline name"
/>
<EditableText
authorised
initialText={questline.desc}
onSubmit={(desc) => onUpdate({ ...questline, desc })}
placeholderText="Description…"
/>
<span className="text-body-secondary small">{questline.game} / {questline.playtype}</span>
<span className="text-body-secondary small">
{questline.game}
</span>
</div>
<button
className="btn btn-outline-danger btn-sm py-0"
@@ -665,6 +951,194 @@ function QuestlineCard({
);
}
// ─── SubmitProposalModal ──────────────────────────────────────────────────────
type SubmitResult = {
prNumber: number;
proposalID: string;
prUrl: string;
status: string;
};
function SubmitProposalModal({
editingProposal,
onAfterSubmit,
onHide,
questlines,
quests,
}: {
editingProposal: MyProposal | null;
onAfterSubmit: () => void;
onHide: () => void;
questlines: Array<RawQuestlineDocument>;
quests: Array<RawQuestDocument>;
}) {
const isUpdate = editingProposal !== null;
const [prTitle, setPrTitle] = useState("");
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<SubmitResult | null>(null);
const [err, setErr] = useState<string | null>(null);
const handleSubmit = async () => {
setErr(null);
setSubmitting(true);
try {
const res = isUpdate
? await APIFetchV1<SubmitResult>(`/proposals/${editingProposal.proposalID}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
quests,
questlines: questlines.length > 0 ? questlines : undefined,
}),
})
: await APIFetchV1<SubmitResult>("/proposals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
quests,
questlines: questlines.length > 0 ? questlines : undefined,
prTitle: prTitle.trim() || undefined,
}),
});
if (!res.success) {
setErr(res.description);
return;
}
setResult(res.body);
onAfterSubmit();
} catch (e) {
setErr((e as Error).message);
} finally {
setSubmitting(false);
}
};
return (
<Modal onHide={onHide} show>
<Modal.Header closeButton>
<Modal.Title>
{isUpdate ? `Update PR #${editingProposal.prNumber}` : "Submit to Community"}
</Modal.Title>
</Modal.Header>
<Modal.Body>
{result ? (
<div>
<Alert variant="success">
{isUpdate ? (
<>
<strong>PR updated!</strong> A new commit has been pushed to{" "}
<a href={result.prUrl} rel="noopener noreferrer" target="_blank">
PR #{result.prNumber}
</a>
.
</>
) : (
<>
<strong>PR opened!</strong> Your quests have been submitted for
review.
</>
)}
</Alert>
<a
className="btn btn-outline-primary btn-sm"
href={result.prUrl}
rel="noopener noreferrer"
target="_blank"
>
<Icon type="code-branch" /> View on GitHub
</a>
</div>
) : (
<>
{isUpdate ? (
<p className="text-body-secondary small">
This will push a new commit to{" "}
<a
href={editingProposal.prUrl}
rel="noopener noreferrer"
target="_blank"
>
PR #{editingProposal.prNumber}
</a>{" "}
with your updated quests.
</p>
) : (
<p className="text-body-secondary small">
This will open a pull request on GitHub with your quests. A
reviewer will check the content before it&apos;s merged into the
site.
</p>
)}
<h6>Quests ({quests.length})</h6>
<ul className="small">
{quests.map((q, i) => (
<li key={i}>
<strong>{q.name}</strong>{" "}
<span className="text-body-secondary">({q.game})</span>
</li>
))}
</ul>
{questlines.length > 0 && (
<>
<h6>Questlines ({questlines.length})</h6>
<ul className="small">
{questlines.map((ql, i) => (
<li key={i}>
<strong>{ql.name}</strong>
</li>
))}
</ul>
</>
)}
{!isUpdate && (
<Form.Group className="mt-3">
<Form.Label>PR title (optional)</Form.Label>
<Form.Control
maxLength={200}
onChange={(e) => setPrTitle(e.target.value)}
placeholder={`Add quest: ${quests.map((q) => q.name).join(", ")}`}
type="text"
value={prTitle}
/>
</Form.Group>
)}
{err && (
<Alert className="mt-3" variant="danger">
{err}
</Alert>
)}
</>
)}
</Modal.Body>
{!result && (
<Modal.Footer>
<Button disabled={submitting} onClick={onHide} variant="secondary">
Cancel
</Button>
<Button disabled={submitting} onClick={handleSubmit} variant="success">
{submitting
? isUpdate
? "Updating…"
: "Submitting…"
: isUpdate
? "Update PR"
: "Submit PR"}
</Button>
</Modal.Footer>
)}
</Modal>
);
}
// ─── ImportJsonModal ──────────────────────────────────────────────────────────
function ImportJsonModal({
@@ -1,13 +0,0 @@
import { useEffect } from "react";
const SEEDS_URL = import.meta.env.VITE_SEEDS_URL as string | undefined;
export default function SeedsViewer() {
useEffect(() => {
if (SEEDS_URL) {
window.location.replace(SEEDS_URL);
}
}, []);
return null;
}
@@ -1,3 +1,5 @@
import MyProposalsPage from "#app/pages/dashboard/proposals/MyProposalsPage";
import ProposalsPage from "#app/pages/dashboard/proposals/ProposalsPage";
import SearchPage from "#app/pages/dashboard/search/SearchPage";
import NotificationsPage from "#app/pages/dashboard/users/NotificationsPage";
import { ErrorPage } from "#app/pages/ErrorPage";
@@ -8,11 +10,13 @@ import DashboardErrorBoundary from "#components/util/DashboardErrorBoundary";
import { BackgroundContext } from "#context/BackgroundContext";
import { BannedContext } from "#context/BannedContext";
import { UserContext } from "#context/UserContext";
import { TachiConfig } from "#lib/config";
import { APIFetchV1, ToAPIURL } from "#util/api";
import React, { useContext, useEffect, useState } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { DashboardPage } from "../pages/dashboard/DashboardPage";
import QuestEditor from "../pages/dashboard/utils/QuestEditor";
import AdminRoutes from "./AdminRoutes";
import GameRoutes from "./GameRoutes";
import ImportRoutes from "./ImportRoutes";
@@ -20,6 +24,14 @@ import { RedirectLegacyUserRoutes } from "./RedirectLegacyRoutes";
import UserRoutes from "./UserRoutes";
import UtilRoutes from "./UtilRoutes";
function QuestEditorRoute() {
if (!TachiConfig.QUEST_PROPOSALS_ENABLED) {
return <Redirect to="/" />;
}
return <QuestEditor />;
}
export default function DashboardRoutes() {
const { user } = useContext(UserContext);
const { banned } = useContext(BannedContext);
@@ -116,10 +128,30 @@ export default function DashboardRoutes() {
<ImportRoutes />
</Route>
<Route exact path="/utils/seeds">
<Redirect to="/" />
</Route>
<Route exact path="/utils/quests">
<Redirect to="/quests" />
</Route>
<Route exact path="/quests">
<QuestEditorRoute />
</Route>
<Route path="/utils">
<UtilRoutes />
</Route>
<Route exact path="/proposals">
<ProposalsPage />
</Route>
<Route exact path="/proposals/mine">
<MyProposalsPage />
</Route>
<Route path="/notifications">
<NotificationsPage />
</Route>
@@ -1,23 +1,13 @@
import ImportAnalysers from "#app/pages/dashboard/utils/ImportAnalysers";
import QuestEditor from "#app/pages/dashboard/utils/QuestEditor";
import SeedsViewer from "#app/pages/dashboard/utils/SeedsViewer";
import React from "react";
import { Route, Switch } from "react-router-dom";
export default function UtilRoutes() {
return (
<Switch>
<Route exact path="/utils/seeds">
<SeedsViewer />
</Route>
<Route path="/utils/imports">
<ImportAnalysers />
</Route>
<Route exact path="/utils/quests">
<QuestEditor />
</Route>
</Switch>
);
}
@@ -6,6 +6,7 @@ import ApiError from "#components/util/ApiError";
import Divider from "#components/util/Divider";
import Loading from "#components/util/Loading";
import useApiQuery from "#components/util/query/useApiQuery";
import { TachiConfig } from "#lib/config";
import { type GamePT } from "#types/react";
import { CreateGoalMap } from "#util/data";
import React from "react";
@@ -48,9 +49,16 @@ function QuestlineSelector({ game }: GamePT) {
<Row>
<Col xs={12}>
<div className="w-100 text-center">
Looks like this game has no quests. If you want, you could{" "}
<Link to="/utils/quests">create your own</Link>, and submit them in the
discord!
Looks like this game has no quests.
{TachiConfig.QUEST_PROPOSALS_ENABLED ? (
<>
{" "}
If you want, you could <Link to="/quests">create your own</Link>, and
submit them in the discord!
</>
) : (
<> You can suggest new quests in the discord!</>
)}
</div>
</Col>
</Row>
@@ -130,7 +130,7 @@ export default function ImportInfo({
are matched with data, all we have to display might be a hash.
<br />
<strong>SongOrChartNotFound</strong> means the score was still{" "}
<strong>saved as an orphan</strong> for nightly matching (around 00:01
<strong>saved as an orphan</strong> for nightly matching (around 1 AM
UTC) or manual reprocess - see{" "}
{user ? (
<Link to={`/u/${user.username}/orphans`}>Orphan scores</Link>
@@ -2,15 +2,16 @@ import useApiQuery from "#components/util/query/useApiQuery";
import { AllLUGPTStatsContext } from "#context/AllLUGPTStatsContext";
import { UserContext } from "#context/UserContext";
import { UserSettingsContext } from "#context/UserSettingsContext";
import { TachiConfig } from "#lib/config";
import { type SetState } from "#types/react";
import React, { useContext, useEffect } from "react";
import Nav from "react-bootstrap/Nav";
import { NavLink } from "react-router-dom";
import { type UserGameStats } from "tachi-common";
import GlobalInfoDropdown from "./GlobalInfoDropdown";
import ImportScoresDropdown from "./ImportScoresDropdown";
import UGPTDropdown from "./UGPTDropdown";
import UtilsDropdown from "./UtilsDropdown";
const toggleClassNames = "w-100 justify-content-between";
const menuClassNames = "shadow-none shadow-lg-lg";
@@ -71,13 +72,25 @@ export function HeaderMenu({
style={dropdownMenuStyle}
/>
)}
{TachiConfig.QUEST_PROPOSALS_ENABLED && (
<NavLink
activeClassName="active"
className="h-14 d-flex align-items-center fw-semibold px-2 rounded text-decoration-none focus-visible-ring nav-link"
onClick={() => setState?.(false)}
to="/quests"
>
Quest Editor
</NavLink>
)}
{settings?.preferences.developerMode && (
<UtilsDropdown
className={toggleClassNames}
menuClassName={menuClassNames}
setState={setState}
style={dropdownMenuStyle}
/>
<NavLink
activeClassName="active"
className="h-14 d-flex align-items-center fw-semibold px-2 rounded text-decoration-none focus-visible-ring nav-link"
onClick={() => setState?.(false)}
to="/utils/imports"
>
Import Management
</NavLink>
)}
</Nav>
);
@@ -1,57 +0,0 @@
import DropdownNavLink from "#components/ui/DropdownNavLink";
import QuickDropdown from "#components/ui/QuickDropdown";
import { type SetState } from "#types/react";
import React from "react";
import DropdownItem from "react-bootstrap/DropdownItem";
const SEEDS_URL = import.meta.env.VITE_SEEDS_URL as string | undefined;
function UtilsLinks({ onClick }: { onClick: React.MouseEventHandler }) {
return (
<>
<DropdownItem
className="rounded focus-visible-ring"
href={SEEDS_URL}
onClick={onClick}
rel="noopener noreferrer"
target="_blank"
>
Seeds Management
</DropdownItem>
<DropdownNavLink onClick={onClick} to="/utils/imports">
Import Management
</DropdownNavLink>
<DropdownNavLink onClick={onClick} to="/utils/quests">
Quest Creator
</DropdownNavLink>
</>
);
}
export default function UtilsDropdown({
className,
menuClassName,
style,
setState,
}: {
className?: string;
menuClassName?: string;
setState?: SetState<boolean>;
style?: React.CSSProperties;
}) {
const handleClick = () => {
setState?.(false);
};
return (
<QuickDropdown
caret
className={`h-14 ${className}`}
menuClassName={menuClassName}
menuStyle={style}
toggle="Developer Utils"
variant="clear"
>
<UtilsLinks onClick={handleClick} />
</QuickDropdown>
);
}
@@ -4,19 +4,14 @@ import { type CellsRenderFN, type QuestWithRelated } from "#types/seeds";
import { StrSOV } from "#util/sorts";
import { type SearchFunctions } from "#util/ztable/search";
import React from "react";
import {
type GameGroup,
type GoalDocument,
LEGACY_FormatGameGroupPT,
type LEGACY_Playtypes,
} from "tachi-common";
import { FormatGame, type GoalDocument, type V3Game } from "tachi-common";
import { type Header } from "../components/TachiTable";
export const SeedsQuestsHeaders: Header<QuestWithRelated>[] = [
["ID", "ID", StrSOV((x) => x.questID)],
["Name", "Name", StrSOV((x) => x.name)],
["GPT", "GPT", StrSOV((x) => x.game)],
["Game", "Game", StrSOV((x) => x.game)],
["Goals", "Goals"],
];
@@ -24,11 +19,7 @@ export const SeedsQuestSearchFns: SearchFunctions<QuestWithRelated> = {
name: (x) => x.name,
questID: (x) => x.questID,
game: (x) => x.game,
gpt: (x) =>
LEGACY_FormatGameGroupPT(
x.game as GameGroup,
x.playtype as LEGACY_Playtypes[typeof x.game],
),
gpt: (x) => FormatGame(x.game as V3Game),
goals: (x) =>
Object.values(x.__related.goals)
.map((e) => e!.name)
@@ -48,10 +39,7 @@ export const SeedsQuestCells: CellsRenderFN<QuestWithRelated> = ({
<strong>{data.name}</strong>
</td>
<td>
{LEGACY_FormatGameGroupPT(
data.game as GameGroup,
data.playtype as LEGACY_Playtypes[typeof data.game],
)}
{FormatGame(data.game as V3Game)}
</td>
<td>
<div style={{ maxHeight: "200px", overflowY: "auto" }}>
@@ -3,14 +3,14 @@ import { type CellsRenderFN, type QuestlineWithRelated } from "#types/seeds";
import { StrSOV } from "#util/sorts";
import { type SearchFunctions } from "#util/ztable/search";
import React from "react";
import { type GameGroup, LEGACY_FormatGameGroupPT, type LEGACY_Playtypes } from "tachi-common";
import { FormatGame, type V3Game } from "tachi-common";
import { type Header } from "../components/TachiTable";
export const SeedsQuestlineHeaders: Header<QuestlineWithRelated>[] = [
["ID", "ID", StrSOV((x) => x.questlineID)],
["Name", "Name", StrSOV((x) => x.name)],
["GPT", "GPT", StrSOV((x) => x.game)],
["Game", "Game", StrSOV((x) => x.game)],
["Quests", "Quests"],
];
@@ -19,11 +19,7 @@ export const SeedsQuestlineSearchFns: SearchFunctions<QuestlineWithRelated> = {
questlineID: (x) => x.questlineID,
desc: (x) => x.desc,
game: (x) => x.game,
gpt: (x) =>
LEGACY_FormatGameGroupPT(
x.game as GameGroup,
x.playtype as LEGACY_Playtypes[typeof x.game],
),
gpt: (x) => FormatGame(x.game as V3Game),
quests: (x) =>
Object.values(x.__related.quests)
.filter((e) => e !== undefined)
@@ -42,10 +38,7 @@ export const SeedsQuestlineCells: CellsRenderFN<QuestlineWithRelated> = ({ data
<Muted>{data.desc}</Muted>
</td>
<td>
{LEGACY_FormatGameGroupPT(
data.game as GameGroup,
data.playtype as LEGACY_Playtypes[typeof data.game],
)}
{FormatGame(data.game as V3Game)}
</td>
<td className="text-start">
<div style={{ maxHeight: "200px", overflowY: "auto" }}>
@@ -56,12 +49,7 @@ export const SeedsQuestlineCells: CellsRenderFN<QuestlineWithRelated> = ({ data
<div key={e}>
{quest ? (
<span>
{quest.name} (
{LEGACY_FormatGameGroupPT(
quest.game as GameGroup,
quest.playtype as LEGACY_Playtypes[typeof quest.game],
)}
)
{quest.name} ({FormatGame(quest.game as V3Game)})
</span>
) : (
<span className="text-danger">UNKNOWN QUEST {e}</span>
@@ -377,7 +377,11 @@ function CriteriaPicker({
game={game}
onChange={(mode, countNum) => {
if (mode === "single") {
setCriteria({ mode: "single", key: criteria.key, value: criteria.value });
setCriteria({
mode: "single",
key: criteria.key,
value: criteria.value,
});
} else {
setCriteria({ ...criteria, countNum: countNum ?? 0, mode });
}
@@ -409,7 +413,11 @@ function CriteriaValuePicker({
switch (conf.type) {
case "NULLABLE_GRAPH":
case "GRAPH":
return <span className="text-body-secondary small">Cannot set goals for graph metrics.</span>;
return (
<span className="text-body-secondary small">
Cannot set goals for graph metrics.
</span>
);
case "DECIMAL":
case "INTEGER":
@@ -457,9 +465,7 @@ function CriteriaModePicker({
criteria: GoalDocument["criteria"];
onChange: (mode: GoalDocument["criteria"]["mode"], countNum?: number) => void;
} & GamePT) {
const [absCount, setAbsCount] = useState(
criteria.mode === "absolute" ? criteria.countNum : 10,
);
const [absCount, setAbsCount] = useState(criteria.mode === "absolute" ? criteria.countNum : 10);
const [perCount, setPerCount] = useState(
criteria.mode === "proportion" ? criteria.countNum * 100 : 10,
);
@@ -635,7 +641,9 @@ function ChartSelect({
loadOptions={loadOptions}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange={(data: any) =>
onChange(Array.isArray(data) ? data.map((e: { value: string }) => e.value) : data.value)
onChange(
Array.isArray(data) ? data.map((e: { value: string }) => e.value) : data.value,
)
}
placeholder="Search for a chart..."
/>
@@ -0,0 +1,69 @@
.track {
background: color-mix(in srgb, var(--bs-body-color) 9%, var(--bs-tertiary-bg));
border: 1px solid color-mix(in srgb, var(--bs-border-color) 70%, transparent);
border-radius: 9999px;
box-shadow:
inset 0 1px 2px rgb(0 0 0 / 0.06),
0 1px 0 rgb(255 255 255 / 0.04);
height: 0.7rem;
overflow: hidden;
position: relative;
}
[data-bs-theme="dark"] .track,
[data-bs-theme="oled"] .track {
box-shadow: inset 0 1px 3px rgb(0 0 0 / 0.35);
}
.fill {
position: relative;
background: linear-gradient(
105deg,
color-mix(in srgb, var(--bs-primary) 72%, var(--bs-body-bg)),
var(--bs-primary) 45%,
color-mix(in srgb, var(--bs-primary) 78%, white)
);
border-radius: inherit;
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.22),
0 0 0 1px color-mix(in srgb, var(--bs-primary) 25%, transparent);
height: 100%;
min-width: 0;
transition: width 0.55s cubic-bezier(0.33, 1, 0.68, 1);
}
.fill::after {
background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.22), transparent);
content: "";
height: 100%;
left: 0;
opacity: 0.85;
pointer-events: none;
position: absolute;
top: 0;
transform: translateX(-100%);
width: 40%;
}
@media (prefers-reduced-motion: no-preference) {
.fillActive::after {
animation: questBarShimmer 2.4s ease-in-out infinite;
}
}
@keyframes questBarShimmer {
0%,
100% {
transform: translateX(-100%);
}
50% {
transform: translateX(350%);
}
}
@media (prefers-reduced-motion: reduce) {
.fill {
transition: none;
}
}
@@ -0,0 +1,33 @@
import React from "react";
import styles from "./QuestProgressBar.module.scss";
/** Renders a themed track + fill for quest / questline completion (0–100%). */
export default function QuestProgressBar({
percent,
"aria-label": ariaLabel,
className,
}: {
"aria-label"?: string;
className?: string;
percent: number;
}) {
const clamped = Math.min(100, Math.max(0, Number.isFinite(percent) ? percent : 0));
const active = clamped > 0 && clamped < 100;
return (
<div
aria-label={ariaLabel}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={Math.round(clamped)}
className={`${styles.track} ${className ?? ""}`}
role="progressbar"
>
<div
className={`${styles.fill} ${active ? styles.fillActive : ""}`}
style={{ width: `${clamped}%` }}
/>
</div>
);
}
@@ -1,6 +1,6 @@
import QuickTooltip from "#components/layout/misc/QuickTooltip";
import Card from "#components/layout/page/Card";
import Divider from "#components/util/Divider";
import QuestProgressBar from "#components/targets/QuestProgressBar";
import Icon from "#components/util/Icon";
import LinkButton from "#components/util/LinkButton";
import Muted from "#components/util/Muted";
@@ -10,7 +10,7 @@ import { APIFetchV1 } from "#util/api";
import { GetGoalIDsFromQuest } from "#util/data";
import { FormatTime } from "#util/time";
import React, { useContext, useState } from "react";
import { Badge, Button, Collapse, ProgressBar } from "react-bootstrap";
import { Badge, Button, Collapse } from "react-bootstrap";
import { type QuestDocument, type QuestlineDocument } from "tachi-common";
export default function Questline({
@@ -35,7 +35,9 @@ export default function Questline({
<div className="d-flex align-items-start justify-content-between gap-3">
<div>
<h4 className="mb-1">{questline.name}</h4>
<Muted>{totalQuests} {totalQuests === 1 ? "Quest" : "Quests"}</Muted>
<Muted>
{totalQuests} {totalQuests === 1 ? "Quest" : "Quests"}
</Muted>
</div>
{!quests && (
<div className="flex-shrink-0">
@@ -60,18 +62,21 @@ export default function Questline({
</Badge>
) : (
<>
<div className="d-flex justify-content-between small text-body-secondary mb-1">
<span>Questline Progress</span>
<span>
{achievedQuests} / {totalQuests} quests complete
<div className="d-flex justify-content-between align-items-baseline gap-2 small text-body-secondary mb-1">
<span className="fw-medium text-body">
Questline progress
</span>
<span className="text-end text-nowrap">
<span className="fw-semibold tabular-nums text-body">
{progressPct}%
</span>
<span className="mx-1 text-body-secondary">·</span>
{achievedQuests} / {totalQuests} quests
</span>
</div>
<ProgressBar
className="rounded-pill"
now={progressPct}
striped={progressPct > 0}
style={{ height: "10px" }}
variant="primary"
<QuestProgressBar
aria-label={`Questline progress: ${progressPct} percent, ${achievedQuests} of ${totalQuests} quests complete`}
percent={progressPct}
/>
</>
)}
@@ -140,7 +145,11 @@ function QuestTimelineNode({
{!isLast && (
<div
className="flex-grow-1 mt-1"
style={{ width: "2px", background: "var(--bs-border-color)", minHeight: "16px" }}
style={{
width: "2px",
background: "var(--bs-border-color)",
minHeight: "16px",
}}
/>
)}
</div>
@@ -222,7 +231,13 @@ function QuestSubscribeButton({ quest }: { quest: QuestDocument }) {
size="sm"
variant="outline-danger"
>
{loading ? "…" : <><Icon type="trash" /> Unsubscribe</>}
{loading ? (
"…"
) : (
<>
<Icon type="trash" /> Unsubscribe
</>
)}
</Button>
</QuickTooltip>
);
@@ -245,7 +260,13 @@ function QuestSubscribeButton({ quest }: { quest: QuestDocument }) {
size="sm"
variant="outline-success"
>
{loading ? "…" : <><Icon type="scroll" /> Subscribe</>}
{loading ? (
"…"
) : (
<>
<Icon type="scroll" /> Subscribe
</>
)}
</Button>
);
}
@@ -271,9 +292,19 @@ export function InnerQuestInfo({ quest }: { quest: QuestDocument }) {
>
<div>
{questSub.achieved ? (
<Icon colour="success" regular style={{ verticalAlign: "middle" }} type="check-square" />
<Icon
colour="success"
regular
style={{ verticalAlign: "middle" }}
type="check-square"
/>
) : (
<Icon colour="danger" regular style={{ verticalAlign: "middle" }} type="square" />
<Icon
colour="danger"
regular
style={{ verticalAlign: "middle" }}
type="square"
/>
)}
</div>
</QuickTooltip>
@@ -1,5 +1,6 @@
import QuickTooltip from "#components/layout/misc/QuickTooltip";
import Card from "#components/layout/page/Card";
import QuestProgressBar from "#components/targets/QuestProgressBar";
import Divider from "#components/util/Divider";
import GoalLink from "#components/util/GoalLink";
import Icon from "#components/util/Icon";
@@ -12,7 +13,7 @@ import { GetGoalIDsFromQuest } from "#util/data";
import { HumanisedJoinArray } from "#util/misc";
import { FormatTime } from "#util/time";
import React, { useContext, useState } from "react";
import { Badge, Button, ProgressBar } from "react-bootstrap";
import { Badge, Button } from "react-bootstrap";
import {
FormatGame,
type GoalDocument,
@@ -44,8 +45,9 @@ export default function Quest({
const [show, setShow] = useState(!collapsible);
const subscribeBtn = user && (
questSub ? (
const subscribeBtn =
user &&
(questSub ? (
<Button
disabled={subscribing}
onClick={async () => {
@@ -62,7 +64,13 @@ export default function Quest({
size="sm"
variant="outline-danger"
>
{subscribing ? "Unsubscribing…" : <><Icon type="trash" /> Unsubscribe</>}
{subscribing ? (
"Unsubscribing…"
) : (
<>
<Icon type="trash" /> Unsubscribe
</>
)}
</Button>
) : (
<Button
@@ -81,10 +89,15 @@ export default function Quest({
size="sm"
variant="success"
>
{subscribing ? "Subscribing…" : <><Icon type="scroll" /> Subscribe</>}
{subscribing ? (
"Subscribing…"
) : (
<>
<Icon type="scroll" /> Subscribe
</>
)}
</Button>
)
);
));
return (
<Card
@@ -108,19 +121,19 @@ export default function Quest({
</Badge>
) : (
<>
<div className="d-flex justify-content-between small text-body-secondary mb-1">
<span>Progress</span>
<span>
<div className="d-flex justify-content-between align-items-baseline gap-2 small text-body-secondary mb-1">
<span className="fw-medium text-body">Progress</span>
<span className="text-end text-nowrap">
<span className="fw-semibold tabular-nums text-body">
{progressPct}%
</span>
<span className="mx-1 text-body-secondary">·</span>
{achievedGoals} / {totalGoals} goals
</span>
</div>
<ProgressBar
className="rounded-pill"
label={`${progressPct}%`}
now={progressPct}
striped={progressPct > 0 && progressPct < 100}
style={{ height: "12px" }}
variant={progressPct === 100 ? "success" : "primary"}
<QuestProgressBar
aria-label={`Quest progress: ${progressPct} percent, ${achievedGoals} of ${totalGoals} goals complete`}
percent={progressPct}
/>
</>
)}
@@ -179,9 +192,7 @@ function QuestSectionComponent({
</Badge>
)}
</div>
{section.desc && (
<p className="text-body-secondary small mb-2">{section.desc}</p>
)}
{section.desc && <p className="text-body-secondary small mb-2">{section.desc}</p>}
<hr className="mt-0 mb-3 opacity-10" />
{section.goals.length === 0 ? (
@@ -199,9 +210,7 @@ function QuestSectionComponent({
);
}
return (
<GoalCard game={game} goal={goal} key={i} note={goalRef.note} />
);
return <GoalCard game={game} goal={goal} key={i} note={goalRef.note} />;
})}
</div>
)}
@@ -274,7 +283,7 @@ function GoalCard({
title="Update this goal"
type="button"
>
<Icon type="pencil" />
<Icon type="pencil-alt" />
</button>
)}
</div>
@@ -315,10 +324,7 @@ export function InnerQuestSectionGoal({
return (
<>
<div className="w-100 d-flex align-items-center gap-2">
<Icon
style={{ verticalAlign: "middle", fontSize: "0.4rem" }}
type="circle"
/>
<Icon style={{ verticalAlign: "middle", fontSize: "0.4rem" }} type="circle" />
<GoalLink goal={goal} />
{onEdit && (
<button
@@ -327,7 +333,7 @@ export function InnerQuestSectionGoal({
title="Update this goal"
type="button"
>
<Icon type="pencil" />
<Icon type="pencil-alt" />
</button>
)}
</div>
@@ -353,11 +359,26 @@ export function InnerQuestSectionGoal({
>
<div>
{isAchieved ? (
<Icon colour="success" regular style={{ verticalAlign: "middle" }} type="check-square" />
<Icon
colour="success"
regular
style={{ verticalAlign: "middle" }}
type="check-square"
/>
) : inProgress ? (
<Icon colour="warning" regular style={{ verticalAlign: "middle" }} type="square" />
<Icon
colour="warning"
regular
style={{ verticalAlign: "middle" }}
type="square"
/>
) : (
<Icon colour="danger" regular style={{ verticalAlign: "middle" }} type="square" />
<Icon
colour="danger"
regular
style={{ verticalAlign: "middle" }}
type="square"
/>
)}
</div>
</QuickTooltip>
@@ -371,7 +392,7 @@ export function InnerQuestSectionGoal({
title="Update this goal"
type="button"
>
<Icon type="pencil" />
<Icon type="pencil-alt" />
</button>
)}
@@ -9,7 +9,7 @@ import { type GamePT } from "#types/react";
import { type RawQuestDocument, type RawQuestGoal, type RawQuestSection } from "#types/tachi";
import { ChangeAtPosition, CopyToClipboard, DeleteInPosition } from "#util/misc";
import React, { useState } from "react";
import { Button, Collapse, Modal } from "react-bootstrap";
import { Button, Modal } from "react-bootstrap";
import { FormatGame } from "tachi-common";
export default function EditableQuest({
@@ -53,7 +53,10 @@ export default function EditableQuest({
})
}
onDelete={() =>
onChange({ ...quest, rawQuestData: DeleteInPosition(quest.rawQuestData, i) })
onChange({
...quest,
rawQuestData: DeleteInPosition(quest.rawQuestData, i),
})
}
onMoveDown={
i < quest.rawQuestData.length - 1
@@ -226,7 +229,11 @@ function QuestSection({
)}
<div className="d-flex gap-2 mt-2">
<Button onClick={() => setShowGoalBuilder(true)} size="sm" variant="outline-success">
<Button
onClick={() => setShowGoalBuilder(true)}
size="sm"
variant="outline-success"
>
<Icon type="plus" /> Add Goal
</Button>
<Button
@@ -284,7 +291,9 @@ function EditableGoalRow({
<div className="d-flex align-items-center gap-2 rounded border px-2 py-1">
<Icon style={{ fontSize: "0.4rem" }} type="circle" />
<span className="flex-grow-1 small">{rawGoal.goal.name}</span>
{rawGoal.note && <span className="text-body-secondary small fst-italic">{rawGoal.note}</span>}
{rawGoal.note && (
<span className="text-body-secondary small fst-italic">{rawGoal.note}</span>
)}
<div className="d-flex gap-1 ms-auto">
<button
@@ -311,7 +320,7 @@ function EditableGoalRow({
title="Edit goal"
type="button"
>
<Icon type="pencil" />
<Icon type="pencil-alt" />
</button>
<button
className="btn btn-outline-danger btn-sm py-0"
@@ -67,9 +67,10 @@ export function UserHeaderBody({ reqUser }: { reqUser: UserDocument }) {
</div>
<div className="col-12 col-lg-6 d-flex justify-content-center flex-column align-items-center">
<StatusComponent reqUser={reqUser} />
{loggedInUser?.authLevel === UserAuthLevels.ADMIN && (
<>
<Divider />
{loggedInUser?.authLevel === UserAuthLevels.ADMIN && (
<>
<Divider />
<div className="d-flex flex-wrap gap-2 justify-content-center">
{reqUser.isSupporter ? (
<Button
onClick={() =>
@@ -107,8 +108,47 @@ export function UserHeaderBody({ reqUser }: { reqUser: UserDocument }) {
Make Supporter?
</Button>
)}
</>
)}
{reqUser.canSubmitQuests ? (
<Button
onClick={() =>
APIFetchV1(
`/admin/quest-submitter/${reqUser.id}`,
{ method: "DELETE" },
true,
true,
).then(async (res) => {
if (res.success) {
await invalidateProfileQueries();
}
})
}
variant="warning"
>
Revoke Quest Submitter?
</Button>
) : (
<Button
onClick={() =>
APIFetchV1(
`/admin/quest-submitter/${reqUser.id}`,
{ method: "POST" },
true,
true,
).then(async (res) => {
if (res.success) {
await invalidateProfileQueries();
}
})
}
variant="outline-success"
>
Grant Quest Submitter
</Button>
)}
</div>
</>
)}
</div>
<div className="col-12 col-lg-3 d-flex justify-content-center">
<ul>
+4 -4
View File
@@ -77,12 +77,12 @@ export type RawQuestGoal = {
* authoring questlines locally in the Quest Editor before uploading as seeds.
*/
export type RawQuestlineDocument = {
desc: string;
/** V3Game identifier, e.g. `"iidx-sp"`. */
game: string;
name: string;
/** User-chosen slug / identifier (becomes `questlineID` in seeds). */
questlineID: string;
name: string;
desc: string;
game: string;
playtype: string;
/** Ordered list of `RawQuestDocument` references by their local index or slug. */
quests: Array<string>;
};
+2
View File
@@ -23,6 +23,8 @@ export interface TachiServerCoreConfig {
NAME: string;
TYPE: "boku" | "kamai" | "omni";
SIGNUPS_ENABLED: boolean;
/** True when the server is configured for community quest proposals (GitHub App). */
QUEST_PROPOSALS_ENABLED: boolean;
}
export * from "./types/api";
+1
View File
@@ -318,6 +318,7 @@ export interface UserDocument {
badges: Array<UserBadges>;
authLevel: UserAuthLevels;
isSupporter?: boolean;
canSubmitQuests?: boolean;
}
export interface SpecificUserGameStats<TGame extends V3Game> {
@@ -180,9 +180,8 @@ const seedsQuestSectionSchema = z.strictObject({
export const SEEDS_QUEST_DOCUMENT_SCHEMA = z.strictObject({
desc: z.string(),
game: SEEDS_V3_GAME_GROUP_SCHEMA,
game: SEEDS_V3_GAME_SCHEMA,
name: z.string(),
playtype: z.string(),
questData: z.array(seedsQuestSectionSchema),
questID: z.string(),
});
@@ -191,9 +190,8 @@ export type SEEDS_QuestDocument = z.infer<typeof SEEDS_QUEST_DOCUMENT_SCHEMA>;
export const SEEDS_QUESTLINE_DOCUMENT_SCHEMA = z.strictObject({
desc: z.string(),
game: SEEDS_V3_GAME_GROUP_SCHEMA,
game: SEEDS_V3_GAME_SCHEMA,
name: z.string(),
playtype: z.string(),
questlineID: z.string(),
quests: z.array(z.string()),
});
@@ -248,10 +246,9 @@ const SEEDS_GOAL_CHARTS_SCHEMA = z.discriminatedUnion("type", [
export const SEEDS_GOAL_DOCUMENT_SCHEMA = z.strictObject({
charts: SEEDS_GOAL_CHARTS_SCHEMA,
criteria: SEEDS_GOAL_CRITERIA_SCHEMA,
game: SEEDS_V3_GAME_GROUP_SCHEMA,
game: SEEDS_V3_GAME_SCHEMA,
goalID: z.string(),
name: z.string(),
playtype: z.string(),
});
export type SEEDS_GoalDocument = z.infer<typeof SEEDS_GOAL_DOCUMENT_SCHEMA>;
+1
View File
@@ -11,6 +11,7 @@ export { type default as InviteLockTable, type InviteLock, type NewInviteLock, t
export { type orphan_chart_id, type default as OrphanChartTable, type OrphanChart, type NewOrphanChart, type OrphanChartUpdate } from './public/OrphanChart';
export { type game_rival_game, type default as GameRivalTable, type GameRival, type NewGameRival, type GameRivalUpdate } from './public/GameRival';
export { type goal_id, type default as GoalTable, type Goal, type NewGoal, type GoalUpdate } from './public/Goal';
export { type quest_proposal_row_id, type default as QuestProposalTable, type QuestProposal, type NewQuestProposal, type QuestProposalUpdate } from './public/QuestProposal';
export { type import_quest_row_id, type default as ImportQuestTable, type ImportQuest, type NewImportQuest, type ImportQuestUpdate } from './public/ImportQuest';
export { type cron_task_execution_id, type default as CronTaskExecutionTable, type CronTaskExecution, type NewCronTaskExecution, type CronTaskExecutionUpdate } from './public/CronTaskExecution';
export { type notification_row_id, type default as NotificationTable, type Notification, type NewNotification, type NotificationUpdate } from './public/Notification';
@@ -48,6 +48,8 @@ export default interface AccountTable {
auth_level: ColumnType<AuthLevel, AuthLevel | undefined, AuthLevel>;
is_supporter: ColumnType<boolean, boolean | undefined, boolean>;
can_submit_quests: ColumnType<boolean, boolean | undefined, boolean>;
}
export type Account = Selectable<AccountTable>;
@@ -11,6 +11,7 @@ import type { default as InviteLockTable } from './InviteLock';
import type { default as OrphanChartTable } from './OrphanChart';
import type { default as GameRivalTable } from './GameRival';
import type { default as GoalTable } from './Goal';
import type { default as QuestProposalTable } from './QuestProposal';
import type { default as ImportQuestTable } from './ImportQuest';
import type { default as CronTaskExecutionTable } from './CronTaskExecution';
import type { default as NotificationTable } from './Notification';
@@ -89,6 +90,8 @@ export default interface PublicSchema {
goal: GoalTable;
quest_proposal: QuestProposalTable;
import_quest: ImportQuestTable;
cron_task_execution: CronTaskExecutionTable;
@@ -0,0 +1,35 @@
// @generated
// This file is automatically generated by Kanel. Do not modify manually.
import type { account_id } from './Account';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.quest_proposal */
export type quest_proposal_row_id = string;
/** Represents the table public.quest_proposal */
export default interface QuestProposalTable {
row_id: ColumnType<quest_proposal_row_id, quest_proposal_row_id | undefined, quest_proposal_row_id>;
user_id: ColumnType<account_id, account_id, account_id>;
github_pr_number: ColumnType<number, number, number>;
github_branch: ColumnType<string, string, string>;
raw_quests: ColumnType<unknown, unknown, unknown>;
raw_questlines: ColumnType<unknown, unknown | undefined, unknown>;
status: ColumnType<string, string | undefined, string>;
created_at: ColumnType<string, string | undefined, string>;
updated_at: ColumnType<string, string | undefined, string>;
}
export type QuestProposal = Selectable<QuestProposalTable>;
export type NewQuestProposal = Insertable<QuestProposalTable>;
export type QuestProposalUpdate = Updateable<QuestProposalTable>;
+11
View File
@@ -34,6 +34,17 @@ function ParseEnvVars() {
clientSecret: process.env.CLIENT_SECRET!,
/** Origin of the static seeds web UI (no trailing slash). */
seedsWebuiOrigin: process.env.SEEDS_WEBUI_ORIGIN ?? "https://seeds.tachi.ac",
/**
* Base URL of the main Tachi server API (no trailing slash).
* When set, the bot will notify the server when a quest-proposal PR is merged.
* Example: https://kamaitachi.xyz/api/v1
*/
tachiApiOrigin: process.env.TACHI_API_ORIGIN ?? "",
/**
* Shared secret for the /proposals/webhook/merged endpoint.
* Must match TACHI_GITHUB_WEBHOOK_SECRET in the main server's env.
*/
tachiWebhookSecret: process.env.TACHI_WEBHOOK_SECRET ?? "",
};
}
+97 -24
View File
@@ -1,4 +1,3 @@
import type { Repository } from "@octokit/webhooks-types";
import { App, createNodeMiddleware } from "@octokit/app";
@@ -33,10 +32,37 @@ function mkSeedDiffViewMsg(baseSha: string, headSha: string, prNumber: number) {
return `\nThis pull request changes files under \`db/seeds/\`. [View the seed diff in the Seeds web UI](${origin}/diff?${params.toString()}).`;
}
/**
* Create a rich comment for quest-proposal PRs, linking to the dedicated
* quest-preview page on seeds.tachi.ac in addition to the regular diff link.
*/
function mkQuestProposalMsg(baseSha: string, headSha: string, prNumber: number) {
const origin = ProcessEnv.seedsWebuiOrigin.replace(/\/$/u, "");
const diffParams = new URLSearchParams({
base: baseSha,
head: headSha,
file: "quests.json",
pr: String(prNumber),
});
const previewUrl = `${origin}/pr/${prNumber}`;
const diffUrl = `${origin}/diff?${diffParams.toString()}`;
return `🎯 **Quest Proposal**
This PR was submitted via the Quest Editor.
| | |
|---|---|
| **Preview quests** | [View on Seeds viewer](${previewUrl}) |
| **Seed diff** | [quests.json diff](${diffUrl}) |
_A reviewer will check the content and merge when it looks good. You can update this PR from the Quest Editor at any time._`;
}
function prTouchesSeeds(files: Array<{ filename: string }>): boolean {
return files.some(
(k) => k.filename.startsWith("db/seeds/") || k.filename === "db/seeds",
);
return files.some((k) => k.filename.startsWith("db/seeds/") || k.filename === "db/seeds");
}
async function listAllPullFiles(
@@ -52,13 +78,16 @@ async function listAllPullFiles(
for (;;) {
// GitHub returns up to 100 files per page; fetch pages sequentially.
// eslint-disable-next-line no-await-in-loop -- pagination must be sequential
const { data } = await octokit.request("GET /repos/{owner}/{repo}/pulls/{pull_number}/files", {
owner,
repo: repoName,
pull_number: pullNumber,
per_page: 100,
page,
});
const { data } = await octokit.request(
"GET /repos/{owner}/{repo}/pulls/{pull_number}/files",
{
owner,
repo: repoName,
pull_number: pullNumber,
per_page: 100,
page,
},
);
const batch = data;
out.push(...batch);
if (batch.length < 100) {
@@ -82,24 +111,35 @@ app.webhooks.on(
["pull_request.opened", "pull_request.reopened", "pull_request.synchronize"],
async ({ octokit, payload }) => {
const repo = payload.repository as Repository;
const pr = payload.pull_request;
const isQuestProposal = pr.head.ref.startsWith("quest-proposal/");
// Quest-proposal PRs get a dedicated preview comment; all other PRs that
// touch db/seeds/ get the standard seed-diff link.
if (isQuestProposal) {
await sendMsg(
mkQuestProposalMsg(pr.base.sha, pr.head.sha, pr.number),
octokit,
repo,
pr.number,
);
return;
}
try {
const filesChanged = await listAllPullFiles(
octokit,
repo.owner.login,
repo.name,
payload.pull_request.number,
pr.number,
);
if (prTouchesSeeds(filesChanged)) {
await sendMsg(
mkSeedDiffViewMsg(
payload.pull_request.base.sha,
payload.pull_request.head.sha,
payload.pull_request.number,
),
mkSeedDiffViewMsg(pr.base.sha, pr.head.sha, pr.number),
octokit,
repo,
payload.pull_request.number,
pr.number,
);
}
} catch (err) {
@@ -115,19 +155,52 @@ ${err}
*****
${mkSeedDiffViewMsg(
payload.pull_request.base.sha,
payload.pull_request.head.sha,
payload.pull_request.number,
)}`,
${mkSeedDiffViewMsg(pr.base.sha, pr.head.sha, pr.number)}`,
octokit,
repo,
payload.pull_request.number,
pr.number,
);
}
},
);
app.webhooks.on(["pull_request.closed"], async ({ payload }) => {
const pr = payload.pull_request;
// Only fire for merged PRs whose branch starts with quest-proposal/
if (!pr.merged || !pr.head.ref.startsWith("quest-proposal/")) {
return;
}
if (!ProcessEnv.tachiApiOrigin || !ProcessEnv.tachiWebhookSecret) {
console.warn(
`Merged quest-proposal PR #${pr.number} but TACHI_API_ORIGIN or TACHI_WEBHOOK_SECRET are not set. Skipping server notification.`,
);
return;
}
try {
const res = await fetch(`${ProcessEnv.tachiApiOrigin}/proposals/webhook/merged`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-tachi-webhook-secret": ProcessEnv.tachiWebhookSecret,
},
body: JSON.stringify({ prNumber: pr.number }),
});
if (!res.ok) {
console.error(
`Failed to notify Tachi server of merged PR #${pr.number}: ${res.status} ${res.statusText}`,
);
} else {
console.log(`Notified Tachi server: quest-proposal PR #${pr.number} merged.`);
}
} catch (err) {
console.error(`Error notifying Tachi server of merged PR #${pr.number}:`, err);
}
});
app.webhooks.on(["issue_comment.created"], async ({ octokit, payload }) => {
const body = payload.comment.body.trim();
@@ -33,7 +33,7 @@ const newGoals: Array<GoalDocument> = [];
function HydrateQuest(raw: RawQuestDocument): QuestDocument {
const questData: QuestDocument["questData"] = [];
const { game, playtype } = raw;
const { game } = raw;
for (const rawQuest of raw.rawQuestData) {
const goals: Array<{ goalID: string; note?: string }> = [];
@@ -45,7 +45,6 @@ function HydrateQuest(raw: RawQuestDocument): QuestDocument {
charts: rawGoal.goal.charts,
criteria: rawGoal.goal.criteria,
game,
playtype,
goalID,
name: rawGoal.goal.name,
} as GoalDocument;
@@ -69,7 +68,6 @@ function HydrateQuest(raw: RawQuestDocument): QuestDocument {
desc: raw.desc,
name: raw.name,
game,
playtype,
// just 20 random bytes. can't think of much more creative at the moment.
questID: CreateQuestID(),
questData,
@@ -190,7 +190,12 @@ function remapChartsBlock(
case "folder":
return {
...charts,
data: resolveFolderGoalRef(charts.data, legacyToSeedFolder, canonicalFolderIds, context),
data: resolveFolderGoalRef(
charts.data,
legacyToSeedFolder,
canonicalFolderIds,
context,
),
};
}
}
@@ -277,14 +282,10 @@ console.log(`goal-id-remap.json: wrote ${translateMap.size} translations`);
const repoRoot = path.resolve(SEEDS_DIR, "..", "..");
const biome = path.join(repoRoot, "node_modules", ".bin", "biome");
const biomeResult = spawnSync(
biome,
["format", "--write", goalsPath, questsPath, remapPath],
{
cwd: repoRoot,
stdio: "inherit",
},
);
const biomeResult = spawnSync(biome, ["format", "--write", goalsPath, questsPath, remapPath], {
cwd: repoRoot,
stdio: "inherit",
});
if (biomeResult.status !== 0) {
throw new Error(`biome format exited ${biomeResult.status}`);
+2
View File
@@ -6,6 +6,7 @@ import { Collection } from "#pages/Collection";
import { Diff } from "#pages/Diff";
import { Overview } from "#pages/Overview";
import { Query } from "#pages/Query";
import { QuestProposalPR } from "#pages/QuestProposalPR";
import React, { useEffect, useState } from "react";
import { QueryClient, QueryClientProvider } from "react-query";
import { BrowserRouter, Redirect, Route, Switch } from "react-router-dom";
@@ -63,6 +64,7 @@ export default function App() {
<Route exact path="/history" render={() => <Redirect to="/diff" />} />
<Route component={Diff} exact path="/diff" />
<Route component={Collection} exact path="/c/:name" />
<Route component={QuestProposalPR} exact path="/pr/:prNumber" />
{editRoutes.map((r) => (
<Route component={r.component} exact key={r.path} path={r.path} />
))}
+6 -3
View File
@@ -355,8 +355,7 @@ function CommitHeader({
}) {
const subject = commit?.message.split("\n")[0] ?? "";
const body = commit?.message.split("\n").slice(1).join("\n").trim() ?? "";
const prNumber =
explicitPrNumber ?? (commit ? extractPrNumber(commit.message) : null);
const prNumber = explicitPrNumber ?? (commit ? extractPrNumber(commit.message) : null);
const commitUrl = `${SEEDS_GITHUB_HTML_URL}/commit/${head}`;
const compareUrl = `${SEEDS_GITHUB_HTML_URL}/compare/${base}...${head}`;
@@ -397,7 +396,11 @@ function CommitHeader({
<div className="commit-header-refs mono">
<span className="commit-ref">
<span className="label">base</span>
<a href={`${SEEDS_GITHUB_HTML_URL}/commit/${base}`} rel="noreferrer" target="_blank">
<a
href={`${SEEDS_GITHUB_HTML_URL}/commit/${base}`}
rel="noreferrer"
target="_blank"
>
<code>{base.slice(0, 7)}</code>
</a>
</span>
@@ -0,0 +1,501 @@
/**
* Quest Proposal PR page — /pr/:prNumber
*
* Shows the quests and questlines introduced by a quest-proposal/* branch PR.
* Fetches the PR head/base SHAs from the GitHub API, then loads the relevant
* seed files from raw.githubusercontent to compute which quests are new or
* updated compared to the base branch.
*/
import { GITHUB_PAT_KEY, SEEDS_DEFAULT_BRANCH, SEEDS_GITHUB_HTML_URL, SEEDS_REPO, SEEDS_REPO_PATH } from "#lib/config";
import { getTransport } from "#lib/transport/transport";
import { useMemo } from "react";
import { useQuery } from "react-query";
import { Link, useParams } from "react-router-dom";
// ─── Local seed-doc types (mirrors tachi-common seeds shape) ─────────────────
type QuestGoalRef = {
goalID: string;
note?: string;
};
type QuestSection = {
goals: QuestGoalRef[];
title?: string;
};
type SeedsQuestDoc = {
desc: string;
game: string;
name: string;
questData: QuestSection[];
questID: string;
};
type SeedsQuestlineDoc = {
desc: string;
game: string;
name: string;
questlineID: string;
quests: Array<{ questID: string }>;
};
type SeedsGoalDoc = {
game: string;
goalID: string;
name: string;
};
// ─── GitHub helpers ───────────────────────────────────────────────────────────
function ghHeaders(): HeadersInit {
const pat = typeof localStorage !== "undefined" ? localStorage.getItem(GITHUB_PAT_KEY) : null;
const headers: Record<string, string> = {
accept: "application/vnd.github+json",
"x-github-api-version": "2022-11-28",
};
if (pat) {
headers.authorization = `Bearer ${pat}`;
}
return headers;
}
async function ghFetch<T>(pathname: string): Promise<T> {
const res = await fetch(`https://api.github.com${pathname}`, { headers: ghHeaders() });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`GitHub ${pathname}: HTTP ${res.status} ${text}`);
}
return (await res.json()) as T;
}
type GhPR = {
base: { ref: string; sha: string };
body: string | null;
head: { ref: string; sha: string };
html_url: string;
number: number;
state: string;
title: string;
user: { avatar_url: string; login: string };
};
// ─── Data hooks ──────────────────────────────────────────────────────────────
function usePR(prNumber: number) {
return useQuery(
["gh-pr", prNumber],
() => ghFetch<GhPR>(`/repos/${SEEDS_REPO}/pulls/${prNumber}`),
{ retry: 1, staleTime: 120_000 },
);
}
function useSeedFile<T>(file: string, sha: string | undefined) {
return useQuery<T[]>(
["seed-file", file, sha ?? "__none__"],
async () => {
if (!sha) {
return [];
}
const t = await getTransport();
return (await t.getCollection(file, sha)) as T[];
},
{ enabled: Boolean(sha), staleTime: 120_000 },
);
}
// ─── Diff helpers ─────────────────────────────────────────────────────────────
function diffById<T extends { [k: string]: unknown }>(
idKey: keyof T,
base: T[],
head: T[],
): { added: T[]; changed: T[]; unchanged: T[] } {
const baseMap = new Map(base.map((x) => [x[idKey] as string, x]));
const headMap = new Map(head.map((x) => [x[idKey] as string, x]));
const added: T[] = [];
const changed: T[] = [];
const unchanged: T[] = [];
for (const [id, item] of headMap) {
const baseItem = baseMap.get(id);
if (!baseItem) {
added.push(item);
} else if (JSON.stringify(item) !== JSON.stringify(baseItem)) {
changed.push(item);
} else {
unchanged.push(item);
}
}
return { added, changed, unchanged };
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function StatusBadge({ state }: { state: string }) {
const cls = state === "open" ? "badge-open" : state === "closed" ? "badge-closed" : "badge-merged";
return <span className={`pr-status-badge ${cls}`}>{state}</span>;
}
function GoalChip({ goal }: { goal: SeedsGoalDoc }) {
return (
<span className="goal-chip" title={goal.goalID}>
{goal.name}
</span>
);
}
function QuestCard({
badge,
goals,
quest,
}: {
badge: "new" | "updated";
goals: Map<string, SeedsGoalDoc>;
quest: SeedsQuestDoc;
}) {
return (
<div className="quest-card">
<div className="quest-card-header">
<span className={`quest-badge quest-badge-${badge}`}>
{badge === "new" ? "New" : "Updated"}
</span>
<span className="quest-game">{quest.game}</span>
<strong className="quest-name">{quest.name}</strong>
</div>
{quest.desc && <p className="quest-desc">{quest.desc}</p>}
<div className="quest-sections">
{quest.questData.map((section, si) => (
<div className="quest-section" key={si}>
{section.title && (
<p className="quest-section-title">{section.title}</p>
)}
<div className="quest-goals">
{section.goals.map((ref) => {
const g = goals.get(ref.goalID);
return g ? (
<span key={ref.goalID}>
<GoalChip goal={g} />
{ref.note && (
<span className="goal-note"> — {ref.note}</span>
)}
</span>
) : (
<span
className="goal-chip goal-chip-missing"
key={ref.goalID}
title={ref.goalID}
>
(unknown goal)
</span>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
function QuestlineCard({
badge,
questline,
questsByID,
}: {
badge: "new" | "updated";
questline: SeedsQuestlineDoc;
questsByID: Map<string, SeedsQuestDoc>;
}) {
return (
<div className="quest-card">
<div className="quest-card-header">
<span className={`quest-badge quest-badge-${badge}`}>
{badge === "new" ? "New" : "Updated"}
</span>
<span className="quest-game">{questline.game}</span>
<strong className="quest-name">{questline.name}</strong>
</div>
{questline.desc && <p className="quest-desc">{questline.desc}</p>}
<div className="questline-quests">
{questline.quests.map((ref, i) => {
const q = questsByID.get(ref.questID);
return (
<span className="questline-step" key={ref.questID}>
<span className="questline-step-num">{i + 1}</span>
{q ? q.name : <em title={ref.questID}>(unknown quest)</em>}
</span>
);
})}
</div>
</div>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export function QuestProposalPR() {
const { prNumber: prNumberStr } = useParams<{ prNumber: string }>();
const prNumber = parseInt(prNumberStr, 10);
const pr = usePR(prNumber);
const headSha = pr.data?.head.sha;
const baseSha = pr.data?.base.sha;
const headQuests = useSeedFile<SeedsQuestDoc>("quests.json", headSha);
const headGoals = useSeedFile<SeedsGoalDoc>("goals.json", headSha);
const headQuestlines = useSeedFile<SeedsQuestlineDoc>("questlines.json", headSha);
const baseQuests = useSeedFile<SeedsQuestDoc>("quests.json", baseSha);
const baseGoals = useSeedFile<SeedsGoalDoc>("goals.json", baseSha);
const baseQuestlines = useSeedFile<SeedsQuestlineDoc>("questlines.json", baseSha);
const questDiff = useMemo(
() =>
headQuests.data && baseQuests.data
? diffById("questID", baseQuests.data, headQuests.data)
: null,
[headQuests.data, baseQuests.data],
);
const questlineDiff = useMemo(
() =>
headQuestlines.data && baseQuestlines.data
? diffById("questlineID", baseQuestlines.data, headQuestlines.data)
: null,
[headQuestlines.data, baseQuestlines.data],
);
// Build lookup maps
const headGoalsByID = useMemo(
() => new Map((headGoals.data ?? []).map((g) => [g.goalID, g])),
[headGoals.data],
);
// All head quests by ID (for questline resolution)
const headQuestsByID = useMemo(
() => new Map((headQuests.data ?? []).map((q) => [q.questID, q])),
[headQuests.data],
);
const isLoading =
pr.isLoading ||
headQuests.isLoading ||
headGoals.isLoading ||
headQuestlines.isLoading ||
baseQuests.isLoading ||
baseGoals.isLoading ||
baseQuestlines.isLoading;
const error = pr.error ?? headQuests.error ?? headQuestlines.error ?? baseQuests.error;
if (Number.isNaN(prNumber)) {
return (
<div>
<h2 className="page-title">Invalid PR</h2>
<p className="page-subtitle">
The PR number in the URL is not valid. <Link to="/">Go home</Link>.
</p>
</div>
);
}
if (pr.error) {
return (
<div>
<h2 className="page-title">PR #{prNumber}</h2>
<p className="page-subtitle text-danger">
Could not load PR: {String(pr.error)}. It may not exist, or it may be a
private repo.
</p>
<p>
<a
href={`${SEEDS_GITHUB_HTML_URL}/pull/${prNumber}`}
rel="noopener noreferrer"
target="_blank"
>
View on GitHub
</a>
</p>
</div>
);
}
const diffParams = pr.data
? new URLSearchParams({
base: pr.data.base.sha,
head: pr.data.head.sha,
file: "quests.json",
pr: String(prNumber),
}).toString()
: null;
const isQuestProposal = pr.data?.head.ref.startsWith("quest-proposal/") ?? false;
return (
<div>
{/* ── PR header ────────────────────────────────────────────── */}
<div className="pr-header">
<div className="pr-header-main">
<h2 className="page-title">
{pr.data ? pr.data.title : `PR #${prNumber}`}
</h2>
{pr.data && (
<div className="pr-meta">
<StatusBadge state={pr.data.state} />
{isQuestProposal && (
<span className="quest-proposal-tag">Quest Proposal</span>
)}
<span className="pr-author">
<img
alt={pr.data.user.login}
className="pr-avatar"
src={pr.data.user.avatar_url}
/>
{pr.data.user.login}
</span>
<span className="pr-branch">
<code>{pr.data.head.ref}</code>
</span>
</div>
)}
</div>
<div className="pr-header-actions">
{pr.data && (
<a
className="btn-ghost"
href={pr.data.html_url}
rel="noopener noreferrer"
target="_blank"
>
GitHub ↗
</a>
)}
{diffParams && (
<Link className="btn-ghost" to={`/diff?${diffParams}`}>
Seed Diff ↗
</Link>
)}
</div>
</div>
{/* ── Loading state ─────────────────────────────────────────── */}
{isLoading && (
<p className="page-subtitle">Loading seed data from GitHub…</p>
)}
{error && !pr.error && (
<p className="text-danger">
Error loading seed files: {String(error)}
</p>
)}
{/* ── Quest diff ────────────────────────────────────────────── */}
{questDiff && (
<section className="mb-4">
<h3 className="section-title">
Quests
{questDiff.added.length > 0 && (
<span className="section-count section-count-added">
+{questDiff.added.length} new
</span>
)}
{questDiff.changed.length > 0 && (
<span className="section-count section-count-changed">
{questDiff.changed.length} updated
</span>
)}
</h3>
{questDiff.added.length === 0 && questDiff.changed.length === 0 && (
<p className="page-subtitle">No quest changes in this PR.</p>
)}
<div className="quest-grid">
{questDiff.added.map((q) => (
<QuestCard
badge="new"
goals={headGoalsByID}
key={q.questID}
quest={q}
/>
))}
{questDiff.changed.map((q) => (
<QuestCard
badge="updated"
goals={headGoalsByID}
key={q.questID}
quest={q}
/>
))}
</div>
</section>
)}
{/* ── Questline diff ────────────────────────────────────────── */}
{questlineDiff &&
(questlineDiff.added.length > 0 || questlineDiff.changed.length > 0) && (
<section className="mb-4">
<h3 className="section-title">
Questlines
{questlineDiff.added.length > 0 && (
<span className="section-count section-count-added">
+{questlineDiff.added.length} new
</span>
)}
{questlineDiff.changed.length > 0 && (
<span className="section-count section-count-changed">
{questlineDiff.changed.length} updated
</span>
)}
</h3>
<div className="quest-grid">
{questlineDiff.added.map((ql) => (
<QuestlineCard
badge="new"
key={ql.questlineID}
questline={ql}
questsByID={headQuestsByID}
/>
))}
{questlineDiff.changed.map((ql) => (
<QuestlineCard
badge="updated"
key={ql.questlineID}
questline={ql}
questsByID={headQuestsByID}
/>
))}
</div>
</section>
)}
{/* ── No quest proposal notice ──────────────────────────────── */}
{pr.data && !isQuestProposal && !isLoading && (
<div className="pr-not-quest-proposal">
<p>
This PR (<code>{pr.data.head.ref}</code>) is not a{" "}
<code>quest-proposal/*</code> branch. The diff above shows any
quest/questline changes it contains, but it was not submitted through
the quest editor.
</p>
<p>
<Link to={`/diff?${diffParams}`}>View the full seed diff →</Link>
</p>
</div>
)}
{/* ── PR body ───────────────────────────────────────────────── */}
{pr.data?.body && (
<details className="pr-body-details">
<summary>PR description</summary>
<pre className="pr-body-pre">{pr.data.body}</pre>
</details>
)}
</div>
);
}
+350
View File
@@ -1856,3 +1856,353 @@ $diff-chg-border: rgba(245, 177, 76, 0.4);
.draft-diff-error {
color: var(--bs-danger-text-emphasis);
}
// ---------------------------------------------------------------------------
// Quest Proposal PR page (/pr/:prNumber)
// ---------------------------------------------------------------------------
.pr-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
padding: 1rem 1.25rem;
background: var(--sw-panel-bg);
border: 1px solid var(--bs-border-color);
border-left: 3px solid $tachi-pink;
border-radius: var(--bs-border-radius);
.pr-header-main {
flex: 1;
min-width: 0;
.page-title {
margin-bottom: 0.35rem;
}
}
.pr-header-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
align-items: flex-start;
padding-top: 0.15rem;
}
}
.pr-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.85rem;
color: var(--bs-secondary-color);
.pr-author {
display: flex;
align-items: center;
gap: 0.35rem;
}
.pr-avatar {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
flex-shrink: 0;
}
.pr-branch code {
font-size: 0.8rem;
}
}
.pr-status-badge {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
flex-shrink: 0;
&.badge-open {
background: rgba(62, 207, 142, 0.15);
color: #3ecf8e;
border: 1px solid rgba(62, 207, 142, 0.35);
}
&.badge-closed {
background: rgba(230, 92, 92, 0.15);
color: #e65c5c;
border: 1px solid rgba(230, 92, 92, 0.35);
}
&.badge-merged {
background: rgba(160, 100, 230, 0.15);
color: #a064e6;
border: 1px solid rgba(160, 100, 230, 0.35);
}
}
.quest-proposal-tag {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.55rem;
border-radius: var(--bs-border-radius-sm);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
background: var(--sw-glow);
color: var(--bs-primary);
border: 1px solid rgba($tachi-pink, 0.3);
flex-shrink: 0;
}
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.75rem;
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
color: var(--bs-body-color);
text-decoration: none;
font-size: 0.82rem;
background: transparent;
transition:
border-color 120ms ease-in-out,
background-color 120ms ease-in-out;
&:hover {
border-color: rgba($tachi-pink, 0.5);
background: var(--sw-glow);
color: var(--bs-body-color);
}
}
.section-title {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.85rem;
.section-count {
font-size: 0.72rem;
font-weight: 600;
padding: 0.1rem 0.45rem;
border-radius: 999px;
letter-spacing: 0.03em;
&.section-count-added {
background: rgba(62, 207, 142, 0.15);
color: #3ecf8e;
border: 1px solid rgba(62, 207, 142, 0.3);
}
&.section-count-changed {
background: rgba(245, 177, 76, 0.12);
color: #f5b14c;
border: 1px solid rgba(245, 177, 76, 0.3);
}
}
}
.quest-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.quest-card {
background: var(--sw-panel-bg);
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
padding: 0.85rem 1rem;
.quest-card-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
flex-wrap: wrap;
}
.quest-badge {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.42rem;
border-radius: 0.25rem;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
flex-shrink: 0;
&.quest-badge-new {
background: rgba(62, 207, 142, 0.15);
color: #3ecf8e;
}
&.quest-badge-updated {
background: rgba(245, 177, 76, 0.12);
color: #f5b14c;
}
}
.quest-game {
font-family: var(--sw-mono);
font-size: 0.75rem;
padding: 0.1rem 0.45rem;
background: var(--bs-body-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius-sm);
color: var(--bs-secondary-color);
flex-shrink: 0;
}
.quest-name {
font-size: 0.95rem;
font-weight: 600;
}
.quest-desc {
font-size: 0.85rem;
color: var(--bs-secondary-color);
margin: 0 0 0.65rem;
line-height: 1.5;
}
.quest-sections {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.quest-section {
.quest-section-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--bs-secondary-color);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 0.35rem;
}
}
.quest-goals {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.questline-quests {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-top: 0.25rem;
}
.questline-step {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.82rem;
.questline-step-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.3rem;
height: 1.3rem;
border-radius: 50%;
background: var(--bs-body-tertiary-bg);
border: 1px solid var(--bs-border-color);
font-size: 0.68rem;
font-weight: 600;
color: var(--bs-secondary-color);
flex-shrink: 0;
}
}
}
.goal-chip {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.55rem;
background: var(--bs-body-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius-sm);
font-size: 0.78rem;
color: var(--bs-body-color);
cursor: default;
max-width: 100%;
overflow-wrap: anywhere;
&.goal-chip-missing {
color: var(--bs-secondary-color);
font-style: italic;
}
}
.goal-note {
font-size: 0.78rem;
color: var(--bs-secondary-color);
}
.pr-not-quest-proposal {
margin-top: 1rem;
padding: 0.85rem 1rem;
background: var(--bs-body-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
font-size: 0.85rem;
color: var(--bs-secondary-color);
p {
margin: 0 0 0.35rem;
&:last-child {
margin: 0;
}
}
}
.pr-body-details {
margin-top: 1.25rem;
summary {
cursor: pointer;
font-size: 0.85rem;
color: var(--bs-secondary-color);
padding: 0.35rem 0;
user-select: none;
&:hover {
color: var(--bs-body-color);
}
}
}
.pr-body-pre {
margin: 0.5rem 0 0;
padding: 0.75rem 1rem;
background: var(--bs-body-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
font-size: 0.82rem;
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
max-height: 24rem;
overflow-y: auto;
font-family: var(--sw-mono);
}
+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,
}));