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