From 53bb75c39f1376632d5c3865effc4d3fdfc7512c Mon Sep 17 00:00:00 2001 From: DaDDy Date: Sat, 16 May 2026 22:05:58 +0630 Subject: [PATCH] feat: implement code challenge solutions for problems 2 through 6 Implement multiple programming challenges including a frontend currency swap interface, a React component analysis, a mathematical summation utility, and a backend CRUD API. - feat(problem2): add currency swap interface with live price fetching and fallback support - feat(problem3): add documentation and analysis of a messy React component - feat(problem4): add multiple TypeScript implementations for summation to N with tests - feat(problem5): add ExpressJS backend with JSON file-based persistence and CRUD endpoints - feat(problem6): add Document of scoreboard API architecture and execution flow - chore: add project configuration including tsconfig, package.json, and gitignore --- .gitignore | 2 + package-lock.json | 1825 ++++++++++++++++++++++++ package.json | 24 + src/problem2/index.html | 145 +- src/problem2/script.js | 368 +++++ src/problem2/style.css | 527 ++++++- src/problem3/README.md | 164 +++ src/problem4/index.test.ts | 24 + src/problem4/index.ts | 79 + src/problem5/README.md | 175 +++ src/problem5/app.ts | 140 ++ src/problem5/constants.ts | 5 + src/problem5/errors.ts | 36 + src/problem5/index.test.ts | 105 ++ src/problem5/index.ts | 27 + src/problem5/json-resource-database.ts | 172 +++ src/problem5/types.ts | 40 + src/problem5/validation.ts | 266 ++++ src/problem6/.keep | 0 src/problem6/README.md | 621 ++++++++ tsconfig.json | 15 + 21 files changed, 4740 insertions(+), 20 deletions(-) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/problem3/README.md create mode 100644 src/problem4/index.test.ts create mode 100644 src/problem4/index.ts create mode 100644 src/problem5/README.md create mode 100644 src/problem5/app.ts create mode 100644 src/problem5/constants.ts create mode 100644 src/problem5/errors.ts create mode 100644 src/problem5/index.test.ts create mode 100644 src/problem5/index.ts create mode 100644 src/problem5/json-resource-database.ts create mode 100644 src/problem5/types.ts create mode 100644 src/problem5/validation.ts create mode 100644 src/problem6/.keep create mode 100644 src/problem6/README.md create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..b947077876 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..a4caf2d4f8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1825 @@ +{ + "name": "code-challenge", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "code-challenge", + "version": "1.0.0", + "dependencies": { + "express": "^5.1.0" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^24.0.0", + "typescript": "^5.8.0", + "vite": "^8.0.13" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..48a4ff1212 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "code-challenge", + "version": "1.0.0", + "private": true, + "description": "Interview code challenge solutions", + "scripts": { + "build": "tsc", + "test": "npm run build && node --test dist/problem4/index.test.js dist/problem5/index.test.js", + "dev:problem2": "vite src/problem2 --host 0.0.0.0", + "build:problem2": "vite build src/problem2 --outDir ../../dist/problem2 --emptyOutDir", + "test:problem4": "npm run build && node --test dist/problem4/index.test.js", + "start:problem5": "npm run build && node dist/problem5/index.js", + "test:problem5": "npm run build && node --test dist/problem5/index.test.js" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^24.0.0", + "typescript": "^5.8.0", + "vite": "^8.0.13" + }, + "dependencies": { + "express": "^5.1.0" + } +} diff --git a/src/problem2/index.html b/src/problem2/index.html index 4058a68bff..49276f0a9b 100644 --- a/src/problem2/index.html +++ b/src/problem2/index.html @@ -1,27 +1,140 @@ - + + - - Fancy Form - - - + + + + Switcheo Swap + + +
+
+ +

Switcheo Exchange

+

Move between assets with a clear quote before you swap.

+

Live token pricing, instant validation, route preview, and a deliberately calm interface for confident currency conversion.

+ +
+
+
Available pairs
+
--
+
+
+
Network fee
+
0.15%
+
+
+
Quote source
+
Loading
+
+
+ +
+
+ +
+
+
+

Asset Swap

+

Exchange

+
+ Fetching prices +
+ +
+
+
+ + Balance -- +
+
+ + +
+
+ $0.00 + +
+
+ + + +
+
+ + Balance -- +
+
+ + +
+
+ $0.00 + Est. after fee +
+
+ + + +
+
+ Rate + -- +
+
+ Minimum received + -- +
+
+ Route + -- +
+
+ +
+ + +
+ + +
+
+
- -
-
Swap
- - + - - +
- -
- + diff --git a/src/problem2/script.js b/src/problem2/script.js index e69de29bb2..b75338cc62 100644 --- a/src/problem2/script.js +++ b/src/problem2/script.js @@ -0,0 +1,368 @@ +const PRICE_URL = "https://interview.switcheo.com/prices.json"; +const ICON_URL = "https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens"; +const NETWORK_FEE = 0.0015; + +const FALLBACK_PRICES = [ + { currency: "ETH", price: 1645.9337373737374, date: "2023-08-29T07:10:52.000Z" }, + { currency: "WBTC", price: 26002.82202020202, date: "2023-08-29T07:10:52.000Z" }, + { currency: "USDC", price: 1, date: "2023-08-29T07:10:30.000Z" }, + { currency: "USD", price: 1, date: "2023-08-29T07:10:30.000Z" }, + { currency: "ATOM", price: 7.186657333333334, date: "2023-08-29T07:10:50.000Z" }, + { currency: "OSMO", price: 0.3772974333333333, date: "2023-08-29T07:10:50.000Z" }, + { currency: "SWTH", price: 0.004039850455012084, date: "2023-08-29T07:10:45.000Z" }, + { currency: "GMX", price: 36.345114372881355, date: "2023-08-29T07:10:40.000Z" }, + { currency: "OKB", price: 42.97562059322034, date: "2023-08-29T07:10:40.000Z" }, + { currency: "ZIL", price: 0.01651813559322034, date: "2023-08-29T07:10:50.000Z" } +]; + +const BALANCES = { + ETH: 2.4862, + WBTC: 0.1848, + USDC: 18640.42, + USD: 9250, + ATOM: 824.25, + OSMO: 4200, + SWTH: 188000, + GMX: 34.8, + OKB: 118.45, + ZIL: 92000 +}; + +const state = { + tokens: [], + from: "ETH", + to: "USDC", + amount: "", + pickerTarget: null, + isSubmitting: false +}; + +const els = { + form: document.querySelector("#swap-form"), + fromAmount: document.querySelector("#from-amount"), + toAmount: document.querySelector("#to-amount"), + fromSymbol: document.querySelector("#from-symbol"), + toSymbol: document.querySelector("#to-symbol"), + fromIcon: document.querySelector("#from-icon"), + toIcon: document.querySelector("#to-icon"), + fromBalance: document.querySelector("#from-balance"), + toBalance: document.querySelector("#to-balance"), + fromValue: document.querySelector("#from-value"), + toValue: document.querySelector("#to-value"), + formError: document.querySelector("#form-error"), + rateLine: document.querySelector("#rate-line"), + minimumLine: document.querySelector("#minimum-line"), + routeLine: document.querySelector("#route-line"), + slippage: document.querySelector("#slippage"), + submitButton: document.querySelector("#submit-button"), + switchButton: document.querySelector("#switch-button"), + maxButton: document.querySelector("#max-button"), + popover: document.querySelector("#token-popover"), + tokenList: document.querySelector("#token-list"), + tokenSearch: document.querySelector("#token-search"), + closePicker: document.querySelector("#close-picker"), + toast: document.querySelector("#toast"), + pairCount: document.querySelector("#pair-count"), + priceSource: document.querySelector("#price-source"), + priceStatus: document.querySelector("#price-status"), + assetStrip: document.querySelector("#asset-strip") +}; + +init(); + +async function init() { + bindEvents(); + setLoadingState(true); + + try { + const response = await fetch(PRICE_URL, { cache: "no-store" }); + if (!response.ok) throw new Error("Price request failed"); + const prices = await response.json(); + state.tokens = normalizePrices(prices); + els.priceSource.textContent = "Live"; + els.priceStatus.textContent = "Live prices"; + } catch (error) { + state.tokens = normalizePrices(FALLBACK_PRICES); + els.priceSource.textContent = "Fallback"; + els.priceStatus.textContent = "Offline prices"; + } finally { + setLoadingState(false); + els.priceStatus.classList.add("ready"); + ensureSelectedTokens(); + renderAll(); + } +} + +function bindEvents() { + els.fromAmount.addEventListener("input", (event) => { + state.amount = sanitizeAmount(event.target.value); + event.target.value = state.amount; + renderQuote(); + }); + + els.slippage.addEventListener("change", renderQuote); + els.switchButton.addEventListener("click", switchTokens); + els.maxButton.addEventListener("click", useMaxBalance); + els.closePicker.addEventListener("click", closePicker); + els.popover.addEventListener("click", (event) => { + if (event.target === els.popover) closePicker(); + }); + els.tokenSearch.addEventListener("input", renderTokenOptions); + + document.querySelectorAll("[data-picker]").forEach((button) => { + button.addEventListener("click", () => openPicker(button.dataset.picker)); + }); + + els.form.addEventListener("submit", submitSwap); +} + +function normalizePrices(prices) { + const byCurrency = new Map(); + + prices.forEach((item) => { + if (!item.currency || !Number.isFinite(Number(item.price)) || Number(item.price) <= 0) return; + + const current = byCurrency.get(item.currency); + const nextDate = new Date(item.date || 0).getTime(); + const currentDate = current ? new Date(current.date || 0).getTime() : -1; + + if (!current || nextDate >= currentDate) { + byCurrency.set(item.currency, { + symbol: item.currency, + price: Number(item.price), + date: item.date || "", + balance: BALANCES[item.currency] ?? makeBalance(item.currency) + }); + } + }); + + return [...byCurrency.values()].sort((a, b) => b.price - a.price); +} + +function makeBalance(symbol) { + const seed = [...symbol].reduce((sum, char) => sum + char.charCodeAt(0), 0); + return Number(((seed % 1800) + 25 + (seed % 19) / 100).toFixed(4)); +} + +function ensureSelectedTokens() { + if (!findToken(state.from)) state.from = state.tokens[0]?.symbol; + if (!findToken(state.to) || state.to === state.from) { + state.to = state.tokens.find((token) => token.symbol !== state.from)?.symbol; + } +} + +function renderAll() { + const count = state.tokens.length; + els.pairCount.textContent = count > 1 ? `${count * (count - 1)}` : "--"; + renderFeaturedAssets(); + renderTokenSelectors(); + renderQuote(); +} + +function renderFeaturedAssets() { + els.assetStrip.innerHTML = state.tokens + .slice(0, 8) + .map((token) => ` + + + ${token.symbol} - ${formatUsd(token.price)} + + `) + .join(""); +} + +function renderTokenSelectors() { + const from = findToken(state.from); + const to = findToken(state.to); + if (!from || !to) return; + + els.fromSymbol.textContent = from.symbol; + els.toSymbol.textContent = to.symbol; + els.fromIcon.src = iconFor(from.symbol); + els.toIcon.src = iconFor(to.symbol); + els.fromIcon.onerror = () => hideBrokenImage(els.fromIcon); + els.toIcon.onerror = () => hideBrokenImage(els.toIcon); + els.fromBalance.textContent = `Balance ${formatToken(from.balance, from.symbol)}`; + els.toBalance.textContent = `Balance ${formatToken(to.balance, to.symbol)}`; +} + +function renderQuote() { + const from = findToken(state.from); + const to = findToken(state.to); + const amount = Number(state.amount); + const validation = validateAmount(amount, from, to); + + if (!from || !to) return; + + const grossReceive = amount > 0 ? amount * from.price / to.price : 0; + const receive = grossReceive * (1 - NETWORK_FEE); + const slippage = Number(els.slippage.value) / 100; + const minimum = receive * (1 - slippage); + + els.toAmount.value = receive ? trimNumber(receive) : ""; + els.fromValue.textContent = formatUsd(amount > 0 ? amount * from.price : 0); + els.toValue.textContent = formatUsd(receive * to.price); + els.rateLine.textContent = `1 ${from.symbol} = ${trimNumber(from.price / to.price)} ${to.symbol}`; + els.minimumLine.textContent = receive ? `${trimNumber(minimum)} ${to.symbol}` : "--"; + els.routeLine.textContent = `${from.symbol} -> USD -> ${to.symbol}`; + els.formError.textContent = validation.message; + els.submitButton.disabled = Boolean(validation.message) || state.isSubmitting; + els.submitButton.textContent = state.isSubmitting ? "Confirming swap..." : "Review swap"; +} + +function validateAmount(amount, from, to) { + if (!from || !to) return { message: "Token prices are still loading." }; + if (from.symbol === to.symbol) return { message: "Choose two different currencies." }; + if (!state.amount) return { message: "Enter an amount to preview your swap." }; + if (!Number.isFinite(amount) || amount <= 0) return { message: "Amount must be greater than zero." }; + if (amount > from.balance) return { message: `Insufficient ${from.symbol} balance.` }; + return { message: "" }; +} + +function openPicker(target) { + state.pickerTarget = target; + els.tokenSearch.value = ""; + els.popover.hidden = false; + document.querySelector(`[data-picker="${target}"]`).setAttribute("aria-expanded", "true"); + renderTokenOptions(); + window.setTimeout(() => els.tokenSearch.focus(), 0); +} + +function closePicker() { + document.querySelectorAll("[data-picker]").forEach((button) => button.setAttribute("aria-expanded", "false")); + state.pickerTarget = null; + els.popover.hidden = true; +} + +function renderTokenOptions() { + const query = els.tokenSearch.value.trim().toLowerCase(); + const activeSymbol = state[state.pickerTarget] || ""; + + const options = state.tokens + .filter((token) => token.symbol.toLowerCase().includes(query)) + .map((token) => ` + + `) + .join(""); + + els.tokenList.innerHTML = options || `

No matching token found.

`; + els.tokenList.querySelectorAll("[data-symbol]").forEach((button) => { + button.addEventListener("click", () => selectToken(button.dataset.symbol)); + }); +} + +function selectToken(symbol) { + const otherSide = state.pickerTarget === "from" ? "to" : "from"; + if (symbol === state[otherSide]) { + state[otherSide] = state[state.pickerTarget]; + } + state[state.pickerTarget] = symbol; + closePicker(); + renderTokenSelectors(); + renderQuote(); +} + +function switchTokens() { + [state.from, state.to] = [state.to, state.from]; + renderTokenSelectors(); + renderQuote(); +} + +function useMaxBalance() { + const from = findToken(state.from); + state.amount = trimNumber(from?.balance || 0); + els.fromAmount.value = state.amount; + renderQuote(); +} + +async function submitSwap(event) { + event.preventDefault(); + const from = findToken(state.from); + const to = findToken(state.to); + const amount = Number(state.amount); + const validation = validateAmount(amount, from, to); + if (validation.message) { + els.formError.textContent = validation.message; + return; + } + + state.isSubmitting = true; + renderQuote(); + await delay(1100); + + const received = Number(els.toAmount.value); + from.balance = Number((from.balance - amount).toFixed(8)); + to.balance = Number((to.balance + received).toFixed(8)); + state.amount = ""; + els.fromAmount.value = ""; + state.isSubmitting = false; + + renderTokenSelectors(); + renderQuote(); + showToast(`Swap confirmed: ${formatToken(amount, from.symbol)} for ${formatToken(received, to.symbol)}.`); +} + +function setLoadingState(isLoading) { + els.submitButton.disabled = isLoading; + els.submitButton.textContent = isLoading ? "Loading market..." : "Review swap"; +} + +function findToken(symbol) { + return state.tokens.find((token) => token.symbol === symbol); +} + +function iconFor(symbol) { + return `${ICON_URL}/${encodeURIComponent(symbol)}.svg`; +} + +function hideBrokenImage(image) { + image.style.visibility = "hidden"; +} + +function sanitizeAmount(value) { + const cleaned = value.replace(/[^0-9.]/g, ""); + const [whole, ...rest] = cleaned.split("."); + const decimal = rest.join("").slice(0, 8); + return rest.length ? `${whole || "0"}.${decimal}` : whole; +} + +function trimNumber(value) { + if (!Number.isFinite(Number(value))) return ""; + return Number(value).toLocaleString("en-US", { + maximumFractionDigits: Number(value) >= 1 ? 6 : 8, + useGrouping: false + }); +} + +function formatToken(value, symbol) { + return `${trimNumber(value)} ${symbol}`; +} + +function formatUsd(value) { + return Number(value || 0).toLocaleString("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: value >= 1 ? 2 : 6 + }); +} + +function formatCompact(value) { + return Number(value || 0).toLocaleString("en-US", { + notation: "compact", + maximumFractionDigits: 2 + }); +} + +function delay(ms) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function showToast(message) { + els.toast.textContent = message; + els.toast.classList.add("visible"); + window.setTimeout(() => els.toast.classList.remove("visible"), 3600); +} diff --git a/src/problem2/style.css b/src/problem2/style.css index 915af91c72..7ad41a0659 100644 --- a/src/problem2/style.css +++ b/src/problem2/style.css @@ -1,8 +1,527 @@ +* { + box-sizing: border-box; +} + +:root { + color-scheme: dark; + --bg: #101113; + --panel: #181b20; + --panel-strong: #20242b; + --line: rgba(255, 255, 255, 0.11); + --text: #f5f2ec; + --muted: #a7abb3; + --soft: #d4d7de; + --accent: #45d19f; + --accent-2: #ffbc57; + --danger: #ff7373; + --shadow: 0 24px 80px rgba(0, 0, 0, 0.45); +} + body { + min-width: 360px; + min-height: 100vh; + margin: 0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--text); + background: + radial-gradient(circle at 16% 12%, rgba(69, 209, 159, 0.15), transparent 26rem), + radial-gradient(circle at 86% 18%, rgba(255, 188, 87, 0.12), transparent 24rem), + linear-gradient(145deg, #111315 0%, #16191e 48%, #101113 100%); +} + +button, +input, +select { + font: inherit; +} + +button { + border: 0; + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; +} + +.app-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(390px, 480px); + gap: 44px; + width: min(1160px, calc(100% - 40px)); + min-height: 100vh; + margin: 0 auto; + align-items: center; + padding: 44px 0; +} + +.market-panel { + min-width: 0; +} + +.brand-mark { + display: grid; + place-items: center; + width: 64px; + height: 64px; + margin-bottom: 28px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 18px; + background: linear-gradient(135deg, rgba(69, 209, 159, 0.22), rgba(255, 188, 87, 0.18)); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.18), 0 20px 60px rgba(0, 0, 0, 0.22); +} + +.brand-mark span { + width: 28px; + height: 28px; + border-radius: 999px; + background: conic-gradient(from 70deg, var(--accent), var(--accent-2), #fa7070, var(--accent)); + box-shadow: 0 0 0 8px rgba(255, 255, 255, 0.07); +} + +.eyebrow { + margin: 0 0 10px; + color: var(--accent); + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + max-width: 720px; + margin-bottom: 18px; + font-size: clamp(2.7rem, 5vw, 5.8rem); + line-height: 0.95; + letter-spacing: 0; +} + +.hero-copy { + max-width: 620px; + margin-bottom: 34px; + color: var(--soft); + font-size: 1.08rem; + line-height: 1.7; +} + +.market-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + max-width: 680px; + margin: 0 0 28px; +} + +.market-stats div, +.asset-chip { + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); +} + +.market-stats div { + padding: 16px; +} + +dt { + color: var(--muted); + font-size: 0.78rem; +} + +dd { + margin: 6px 0 0; + font-size: 1.2rem; + font-weight: 800; +} + +.asset-strip { display: flex; - flex-direction: row; + flex-wrap: wrap; + gap: 10px; + max-width: 680px; +} + +.asset-chip { + display: inline-flex; align-items: center; - justify-content: center; - min-width: 360px; - font-family: Arial, Helvetica, sans-serif; + gap: 8px; + padding: 9px 11px; + color: var(--soft); + font-size: 0.88rem; +} + +.asset-chip img, +.token-button img, +.token-option img { + width: 26px; + height: 26px; + border-radius: 999px; + background: #2a2f37; +} + +.swap-card { + position: relative; + border: 1px solid rgba(255, 255, 255, 0.13); + border-radius: 8px; + padding: 24px; + background: linear-gradient(180deg, rgba(32, 36, 43, 0.94), rgba(24, 27, 32, 0.94)); + box-shadow: var(--shadow); +} + +.card-heading, +.field-row, +.token-row, +.settings-row, +.popover-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.card-heading { + margin-bottom: 20px; +} + +.card-heading h2 { + margin: 0; + font-size: 2rem; +} + +.status-pill { + padding: 8px 10px; + border-radius: 999px; + color: var(--accent-2); + background: rgba(255, 188, 87, 0.12); + font-size: 0.78rem; + font-weight: 800; +} + +.status-pill.ready { + color: var(--accent); + background: rgba(69, 209, 159, 0.12); +} + +.amount-box { + border: 1px solid var(--line); + border-radius: 8px; + padding: 16px; + background: rgba(255, 255, 255, 0.04); +} + +.field-row { + min-height: 24px; + color: var(--muted); + font-size: 0.86rem; +} + +.field-row label { + color: var(--soft); + font-weight: 750; +} + +.subtle { + margin-top: 8px; +} + +.token-row { + margin-top: 10px; +} + +.token-row input { + min-width: 0; + width: 100%; + border: 0; + outline: 0; + color: var(--text); + background: transparent; + font-size: clamp(1.8rem, 5vw, 2.65rem); + font-weight: 800; + letter-spacing: 0; +} + +.token-row input::placeholder { + color: rgba(255, 255, 255, 0.24); +} + +.token-button { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + gap: 9px; + min-width: 126px; + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: var(--text); + background: #101317; + font-weight: 850; +} + +.chevron { + margin-left: auto; + color: var(--muted); +} + +.ghost-button { + padding: 4px 8px; + border-radius: 7px; + color: var(--accent); + background: rgba(69, 209, 159, 0.12); + font-weight: 800; +} + +.switch-button { + display: grid; + place-items: center; + width: 42px; + height: 42px; + margin: -2px auto; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--text); + background: linear-gradient(135deg, #2a3038, #111418); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); + transform: translateY(0); + transition: transform 160ms ease, border-color 160ms ease; +} + +.switch-button:hover { + border-color: rgba(69, 209, 159, 0.55); + transform: translateY(-1px); +} + +.form-error { + min-height: 22px; + margin: 12px 0; + color: var(--danger); + font-size: 0.9rem; +} + +.quote-panel { + display: grid; + gap: 10px; + margin-bottom: 16px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(0, 0, 0, 0.18); +} + +.quote-panel div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--muted); + font-size: 0.86rem; +} + +.quote-panel strong { + color: var(--soft); + text-align: right; +} + +.settings-row { + margin-bottom: 18px; + color: var(--muted); + font-size: 0.9rem; +} + +.settings-row select { + border: 1px solid var(--line); + border-radius: 8px; + padding: 9px 12px; + color: var(--text); + background: #101317; +} + +.submit-button { + width: 100%; + min-height: 54px; + border-radius: 8px; + color: #07110d; + background: linear-gradient(135deg, var(--accent), #9be47a); + font-size: 1rem; + font-weight: 900; + box-shadow: 0 18px 42px rgba(69, 209, 159, 0.25); +} + +.submit-button:disabled { + opacity: 0.55; + box-shadow: none; +} + +.token-popover { + position: fixed; + inset: 0; + z-index: 20; + display: grid; + place-items: center; + padding: 20px; + background: rgba(0, 0, 0, 0.58); + backdrop-filter: blur(10px); +} + +.token-popover[hidden] { + display: none; +} + +.popover-panel { + width: min(430px, 100%); + max-height: min(680px, calc(100vh - 40px)); + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: #171b21; + box-shadow: var(--shadow); +} + +.popover-header { + padding: 18px 18px 8px; +} + +.popover-header h3 { + margin: 0; +} + +.popover-header button { + width: 34px; + height: 34px; + border-radius: 8px; + color: var(--text); + background: rgba(255, 255, 255, 0.08); + font-size: 1.4rem; +} + +.token-search { + width: calc(100% - 36px); + margin: 10px 18px 12px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px 13px; + outline: 0; + color: var(--text); + background: #101317; +} + +.token-list { + max-height: 430px; + overflow: auto; + padding: 6px 10px 12px; +} + +.token-option { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + width: 100%; + padding: 11px 10px; + border-radius: 8px; + color: var(--text); + background: transparent; + text-align: left; +} + +.token-option:hover, +.token-option[aria-selected="true"] { + background: rgba(255, 255, 255, 0.07); +} + +.token-option strong, +.token-option span { + display: block; +} + +.token-option span { + margin-top: 2px; + color: var(--muted); + font-size: 0.82rem; +} + +.token-option em { + color: var(--soft); + font-style: normal; + font-weight: 800; +} + +.toast { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 30; + max-width: min(420px, calc(100% - 40px)); + padding: 14px 16px; + border: 1px solid rgba(69, 209, 159, 0.35); + border-radius: 8px; + color: var(--text); + background: #15221d; + box-shadow: var(--shadow); + opacity: 0; + transform: translateY(12px); + pointer-events: none; + transition: opacity 180ms ease, transform 180ms ease; +} + +.toast.visible { + opacity: 1; + transform: translateY(0); +} + +@media (max-width: 900px) { + .app-shell { + grid-template-columns: 1fr; + gap: 28px; + align-items: start; + padding-top: 28px; + } + + h1 { + font-size: clamp(2.35rem, 12vw, 4rem); + } + + .hero-copy { + margin-bottom: 22px; + } +} + +@media (max-width: 560px) { + .app-shell { + width: min(100% - 24px, 1160px); + } + + .market-stats { + grid-template-columns: 1fr; + } + + .swap-card { + padding: 18px; + } + + .token-row { + align-items: stretch; + flex-direction: column; + } + + .token-button { + width: 100%; + } + + .quote-panel div, + .settings-row { + align-items: flex-start; + flex-direction: column; + } + + .quote-panel strong { + text-align: left; + } } diff --git a/src/problem3/README.md b/src/problem3/README.md new file mode 100644 index 0000000000..587b212fac --- /dev/null +++ b/src/problem3/README.md @@ -0,0 +1,164 @@ +# Problem 3 - Messy React + +The original component mixes correctness issues, avoidable recomputation, weak +typing, and React anti-patterns. The largest problems are not stylistic; several +of them change the rendered output or make the code fail TypeScript compilation. + +## Issues Found + +### Type and compile-time correctness + +- `WalletBalance` does not define `blockchain`, but the component reads + `balance.blockchain`. This should be part of the domain type. +- `getPriority` accepts `any`, which removes TypeScript's protection around a + known blockchain value. +- The filter references `lhsPriority`, which is not defined. The intended + variable appears to be `balancePriority`. +- `rows` maps over `sortedBalances` but types each item as + `FormattedWalletBalance`. At runtime, `sortedBalances` does not include the + `formatted` field. +- The `sort` comparator can return `undefined` when priorities are equal. A + comparator must return a number for every branch. +- `Props extends BoxProps` is empty and the component renders a native `div`. + Unless `BoxProps` is intentionally supported by that `div` or a `Box` + component, this is an inaccurate public contract. +- `classes.row` is used but `classes` is not defined in the snippet. + +### Business logic bugs + +- The filter condition is inverted. It currently keeps balances with + `amount <= 0` when the priority is valid. A wallet page should normally show + positive balances and hide zero or negative balances. +- The default priority is `-99`, but the broken filter condition makes the + eligibility rule hard to read and easy to misuse. +- `formattedBalances` is calculated but never used for rendering. +- `balance.amount.toFixed()` defaults to zero decimal places, which is usually + too lossy for wallet/token balances. Formatting should be explicit. +- `prices[balance.currency] * balance.amount` can produce `NaN` when a price is + missing. + +### React and performance anti-patterns + +- `getPriority` is recreated on every render and is listed indirectly inside a + memoized calculation. A static lookup table is simpler and more stable. +- `prices` is included in the `sortedBalances` dependency array even though + sorting/filtering does not use prices. That causes unnecessary recomputation + whenever prices change. +- The code performs multiple passes over the same data: + `filter -> sort -> map formattedBalances -> map rows`. The unused + `formattedBalances` pass is pure waste, and the final row data can be prepared + in one memoized transformation after sorting. +- `Array.prototype.sort` mutates its input. In this specific code the array + returned by `filter` is new, so `balances` is not mutated, but relying on this + detail is fragile. Copying before sorting communicates the intent clearly. +- Using `index` as the React `key` can cause incorrect row identity when the + list is sorted or changes. A stable key from the data should be used. +- The component accepts `children` but never renders it. Either render children + intentionally or remove it from the destructuring. +- Rendering rows can be memoized as derived row data first, keeping JSX mapping + simple and avoiding stale or mismatched derived values. + +## Refactored Version + +```tsx +interface WalletBalance { + currency: string; + amount: number; + blockchain: Blockchain; +} + +interface FormattedWalletBalance extends WalletBalance { + formatted: string; + usdValue: number; +} + +type Blockchain = 'Osmosis' | 'Ethereum' | 'Arbitrum' | 'Zilliqa' | 'Neo'; + +interface Props extends BoxProps { + children?: React.ReactNode; +} + +const BLOCKCHAIN_PRIORITY: Record = { + Osmosis: 100, + Ethereum: 50, + Arbitrum: 30, + Zilliqa: 20, + Neo: 20, +}; + +const UNKNOWN_BLOCKCHAIN_PRIORITY = -99; + +const getPriority = (blockchain: string): number => { + return BLOCKCHAIN_PRIORITY[blockchain as Blockchain] ?? UNKNOWN_BLOCKCHAIN_PRIORITY; +}; + +const formatTokenAmount = (amount: number): string => { + return amount.toFixed(2); +}; + +const WalletPage: React.FC = ({ children, ...rest }) => { + const balances = useWalletBalances(); + const prices = usePrices(); + + const rows = useMemo(() => { + return [...balances] + .filter((balance) => { + return getPriority(balance.blockchain) > UNKNOWN_BLOCKCHAIN_PRIORITY + && balance.amount > 0; + }) + .sort((left, right) => { + return getPriority(right.blockchain) - getPriority(left.blockchain); + }) + .map((balance) => { + const price = prices[balance.currency] ?? 0; + + return { + ...balance, + formatted: formatTokenAmount(balance.amount), + usdValue: price * balance.amount, + }; + }); + }, [balances, prices]); + + return ( +
+ {children} + {rows.map((balance) => ( + + ))} +
+ ); +}; +``` + +## Why This Is Better + +- The transformation now has one clear data flow: validate balances, sort by + priority, then derive display values. +- TypeScript describes the real data shape instead of hiding missing fields with + `any`. +- Filtering now keeps only supported blockchains with positive balances. +- The sort comparator is deterministic and always returns a number. +- Price and formatted amount are calculated in the same memoized transformation + that produces the renderable row data. +- React keys are stable across sorting and insertion. +- Missing prices produce a safe zero USD value instead of `NaN`. + +## Further Production Improvements + +- Replace `balance.amount` as `number` with a decimal-safe representation if + token precision matters. JavaScript floating-point numbers are not ideal for + financial calculations. +- Use `Intl.NumberFormat` or a token-aware formatter for display formatting + instead of a hardcoded `toFixed(2)`. +- If unsupported blockchains should still render with low priority, keep them + with a default priority instead of filtering them out. +- Prefer a concrete container prop type, such as + `React.ComponentPropsWithoutRef<'div'>`, if the component renders a `div` + rather than a design-system `Box`. diff --git a/src/problem4/index.test.ts b/src/problem4/index.test.ts new file mode 100644 index 0000000000..72bacb5272 --- /dev/null +++ b/src/problem4/index.test.ts @@ -0,0 +1,24 @@ +import { strictEqual } from "node:assert"; +import { test } from "node:test"; + +import { sum_to_n_a, sum_to_n_b, sum_to_n_c } from "./index"; + +const implementations = [ + ["sum_to_n_a", sum_to_n_a], + ["sum_to_n_b", sum_to_n_b], + ["sum_to_n_c", sum_to_n_c], +] as const; + +for (const [name, sumToN] of implementations) { + test(`${name} returns the summation from 1 to n`, () => { + strictEqual(sumToN(1), 1); + strictEqual(sumToN(5), 15); + strictEqual(sumToN(10), 55); + strictEqual(sumToN(100), 5050); + }); + + test(`${name} returns 0 for non-positive input`, () => { + strictEqual(sumToN(0), 0); + strictEqual(sumToN(-5), 0); + }); +} diff --git a/src/problem4/index.ts b/src/problem4/index.ts new file mode 100644 index 0000000000..e7bcb1c6f3 --- /dev/null +++ b/src/problem4/index.ts @@ -0,0 +1,79 @@ +/** + * @file Problem 4 - Summation to N + * @description Provides three unique TypeScript implementations for calculating + * the summation from 1 to n. + * @author Paing Sett Kyaw + * @created 2026-05-15 + * @updated 2026-05-15 + */ + +/** + * @description Calculates the summation from 1 to n using a standard iterative + * loop. + * @param n - Any integer input. + * @returns The summation from 1 to n. Returns 0 when n is less than or equal to 0. + * @complexity Time: O(n), because each number from 1 through n is visited once. + * Space: O(1), because only a running total is stored. + */ +function sum_to_n_a(n: number): number { + if (n <= 0) { + return 0; + } + + let total = 0; + + for (let current = 1; current <= n; current += 1) { + total += current; + } + + return total; +} + +/** + * @description Calculates the summation from 1 to n using the arithmetic series + * formula. + * @param n - Any integer input. + * @returns The summation from 1 to n. Returns 0 when n is less than or equal to 0. + * @complexity Time: O(1), because the result is calculated directly with a + * formula. Space: O(1), because no extra data structure is created. + */ +function sum_to_n_b(n: number): number { + if (n <= 0) { + return 0; + } + + return (n * (n + 1)) / 2; +} + +/** + * @description Calculates the summation from 1 to n by pairing the smallest and + * largest remaining values. + * @param n - Any integer input. + * @returns The summation from 1 to n. Returns 0 when n is less than or equal to 0. + * @complexity Time: O(n), but it performs about half as many loop iterations as + * the direct iterative solution. Space: O(1), because only counters and a + * running total are stored. + */ +function sum_to_n_c(n: number): number { + if (n <= 0) { + return 0; + } + + let low = 1; + let high = n; + let total = 0; + + while (low < high) { + total += low + high; + low += 1; + high -= 1; + } + + if (low === high) { + total += low; + } + + return total; +} + +export { sum_to_n_a, sum_to_n_b, sum_to_n_c }; diff --git a/src/problem5/README.md b/src/problem5/README.md new file mode 100644 index 0000000000..d7b303d213 --- /dev/null +++ b/src/problem5/README.md @@ -0,0 +1,175 @@ +# Problem 5 - A Crude Server + +This solution implements a TypeScript ExpressJS backend with CRUD endpoints for +a `resource` entity. Data is persisted in a small JSON database file so records +remain available after the server restarts. + +## Requirements Covered + +- Create a resource. +- List resources with basic filters. +- Get details of one resource. +- Update resource details. +- Delete a resource. +- Persist data through a simple file-backed database. + +## Configuration + +The server supports these environment variables: + +| Variable | Default | Description | +| --- | --- | --- | +| `PORT` | `3000` | HTTP port used by the Express server. | +| `DATABASE_FILE` | `dist/problem5/database.json` | JSON database file path. | + +## Run The Application + +Install dependencies: + +```bash +npm install +``` + +Build and start Problem 5: + +```bash +npm run start:problem5 +``` + +The server will run at: + +```text +http://localhost:3000 +``` + +Use a custom port or database file: + +```bash +PORT=4000 DATABASE_FILE=./problem5-db.json npm run start:problem5 +``` + +## Project Structure + +```text +src/problem5/ + index.ts # Server entrypoint and public exports + app.ts # Express app and route handlers + json-resource-database.ts # File-backed resource repository + validation.ts # Request and database validation helpers + errors.ts # HTTP error and centralized error middleware + types.ts # Shared resource and repository types + constants.ts # Shared constants + index.test.ts # CRUD and validation tests +``` + +## API Endpoints + +### API Home + +```http +GET / +``` + +Shows a simple browser-friendly page confirming the API is running and linking +to useful endpoints. + +### Health Check + +```http +GET /health +``` + +### Create Resource + +```http +POST /resources +Content-Type: application/json +``` + +```json +{ + "title": "Interview preparation notes", + "description": "Backend CRUD practice task", + "category": "study", + "status": "active", + "tags": ["typescript", "express"] +} +``` + +Required fields: + +- `title` +- `category` + +Optional fields: + +- `description` +- `status`: `active` or `archived` +- `tags`: array of strings + +### List Resources + +```http +GET /resources +``` + +Basic filters: + +```http +GET /resources?category=study +GET /resources?status=active +GET /resources?tag=typescript +GET /resources?search=backend +``` + +Filters can be combined: + +```http +GET /resources?category=study&status=active&tag=typescript +``` + +### Get Resource Details + +```http +GET /resources/:id +``` + +### Update Resource + +```http +PATCH /resources/:id +Content-Type: application/json +``` + +```json +{ + "title": "Updated interview notes", + "status": "archived" +} +``` + +### Delete Resource + +```http +DELETE /resources/:id +``` + +Successful deletion returns `204 No Content`. + +## Example Curl Flow + +```bash +curl -X POST http://localhost:3000/resources \ + -H "Content-Type: application/json" \ + -d '{"title":"Interview preparation notes","category":"study","tags":["typescript","express"]}' + +curl http://localhost:3000/resources + +curl "http://localhost:3000/resources?category=study&tag=typescript" + +curl -X PATCH http://localhost:3000/resources/ \ + -H "Content-Type: application/json" \ + -d '{"status":"archived"}' + +curl -X DELETE http://localhost:3000/resources/ +``` diff --git a/src/problem5/app.ts b/src/problem5/app.ts new file mode 100644 index 0000000000..11340db8d0 --- /dev/null +++ b/src/problem5/app.ts @@ -0,0 +1,140 @@ +import express, { type Request, type RequestHandler, type Response } from "express"; +import { HttpError, errorHandler } from "./errors"; +import { JsonResourceDatabase } from "./json-resource-database"; +import { type ResourceRepository } from "./types"; +import { + parseCreateResourceInput, + parseResourceFilters, + parseUpdateResourceInput, + readRouteId, +} from "./validation"; + +/** + * @description Creates the Express application and registers all Problem 5 routes. + * @param repository - Resource persistence implementation used by route handlers. + * @returns Configured Express application instance. + */ +export function createApp( + repository: ResourceRepository = new JsonResourceDatabase(), +): express.Express { + const app = express(); + + app.use(express.json()); + + app.get("/", renderHomePage); + app.get("/health", healthCheck); + + app.get( + "/resources", + asyncRoute(async (request, response) => { + const resources = await repository.list(parseResourceFilters(request)); + + response.json({ data: resources, count: resources.length }); + }), + ); + + app.post( + "/resources", + asyncRoute(async (request, response) => { + const resource = await repository.create(parseCreateResourceInput(request.body)); + + response.status(201).json({ data: resource }); + }), + ); + + app.get( + "/resources/:id", + asyncRoute(async (request, response) => { + const resource = await repository.findById(readRouteId(request)); + + if (!resource) { + throw new HttpError(404, "Resource not found"); + } + + response.json({ data: resource }); + }), + ); + + app.patch( + "/resources/:id", + asyncRoute(async (request, response) => { + const resource = await repository.update( + readRouteId(request), + parseUpdateResourceInput(request.body), + ); + + if (!resource) { + throw new HttpError(404, "Resource not found"); + } + + response.json({ data: resource }); + }), + ); + + app.delete( + "/resources/:id", + asyncRoute(async (request, response) => { + const deleted = await repository.delete(readRouteId(request)); + + if (!deleted) { + throw new HttpError(404, "Resource not found"); + } + + response.status(204).send(); + }), + ); + + app.use(errorHandler); + + return app; +} + +/** + * @description Renders a simple browser-friendly API home page. + * @param _request - Incoming Express request. + * @param response - Express response used to send HTML. + */ +function renderHomePage(_request: Request, response: Response): void { + response.type("html").send(` + + + + + + Problem 5 API + + +
+

Problem 5 API is running

+

Use the resources API to create, list, view, update, and delete resources.

+ +
+ + + `); +} + +/** + * @description Returns a health-check response for uptime checks. + * @param _request - Incoming Express request. + * @param response - Express response used to send JSON. + */ +function healthCheck(_request: Request, response: Response): void { + response.json({ ok: true }); +} + +/** + * @description Wraps async route handlers and forwards errors to Express. + * @param handler - Async route handler to execute. + * @returns Express-compatible request handler. + */ +function asyncRoute( + handler: (request: Request, response: Response) => Promise, +): RequestHandler { + return (request, response, next) => { + handler(request, response).catch(next); + }; +} diff --git a/src/problem5/constants.ts b/src/problem5/constants.ts new file mode 100644 index 0000000000..661ff6ad5e --- /dev/null +++ b/src/problem5/constants.ts @@ -0,0 +1,5 @@ +import path from "node:path"; + +export const RESOURCE_STATUSES = ["active", "archived"] as const; +export const DEFAULT_PORT = 3000; +export const DEFAULT_DATABASE_FILE = path.join(__dirname, "database.json"); diff --git a/src/problem5/errors.ts b/src/problem5/errors.ts new file mode 100644 index 0000000000..f732ff0897 --- /dev/null +++ b/src/problem5/errors.ts @@ -0,0 +1,36 @@ +import { type ErrorRequestHandler, type NextFunction, type Request, type Response } from "express"; + +/** + * @description Represents an HTTP-safe error returned by the API layer. + */ +export class HttpError extends Error { + /** + * @description Creates an error with a response status code. + * @param statusCode - HTTP status code sent to the client. + * @param message - Public error message sent to the client. + */ + public constructor( + public readonly statusCode: number, + message: string, + ) { + super(message); + } +} + +/** + * @description Converts known application errors into JSON responses. + */ +export const errorHandler: ErrorRequestHandler = ( + error: unknown, + _request: Request, + response: Response, + _next: NextFunction, +) => { + if (error instanceof HttpError) { + response.status(error.statusCode).json({ error: error.message }); + return; + } + + console.error(error); + response.status(500).json({ error: "Internal server error" }); +}; diff --git a/src/problem5/index.test.ts b/src/problem5/index.test.ts new file mode 100644 index 0000000000..498e72e92c --- /dev/null +++ b/src/problem5/index.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createApp, JsonResourceDatabase } from "./index"; + +test("Problem 5 CRUD flow persists resources and supports filters", async () => { + const tempDirectory = await mkdtemp(path.join(tmpdir(), "problem5-")); + const databaseFile = path.join(tempDirectory, "database.json"); + const app = createApp(new JsonResourceDatabase(databaseFile)); + const server = app.listen(0); + + try { + const address = server.address(); + assert(address && typeof address === "object"); + + const baseUrl = `http://127.0.0.1:${address.port}`; + + const created = await request(baseUrl, "/resources", { + method: "POST", + body: { + title: "Interview preparation notes", + description: "Backend CRUD practice task", + category: "study", + tags: ["typescript", "express"], + }, + }); + + assert.equal(created.status, 201); + assert.equal(created.body.data.title, "Interview preparation notes"); + + const id = created.body.data.id as string; + const filtered = await request( + baseUrl, + "/resources?category=study&tag=typescript&search=backend", + ); + + assert.equal(filtered.status, 200); + assert.equal(filtered.body.count, 1); + + const detail = await request(baseUrl, `/resources/${id}`); + assert.equal(detail.status, 200); + assert.equal(detail.body.data.id, id); + + const updated = await request(baseUrl, `/resources/${id}`, { + method: "PATCH", + body: { status: "archived" }, + }); + + assert.equal(updated.status, 200); + assert.equal(updated.body.data.status, "archived"); + + const deleted = await request(baseUrl, `/resources/${id}`, { method: "DELETE" }); + assert.equal(deleted.status, 204); + + const missing = await request(baseUrl, `/resources/${id}`); + assert.equal(missing.status, 404); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await rm(tempDirectory, { recursive: true, force: true }); + } +}); + +test("Problem 5 validates request bodies", async () => { + const app = createApp(new JsonResourceDatabase(path.join(tmpdir(), "problem5-validation.json"))); + const server = app.listen(0); + + try { + const address = server.address(); + assert(address && typeof address === "object"); + + const response = await request(`http://127.0.0.1:${address.port}`, "/resources", { + method: "POST", + body: { category: "study" }, + }); + + assert.equal(response.status, 400); + assert.equal(response.body.error, "title is required and must be a non-empty string"); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +async function request( + baseUrl: string, + pathname: string, + options: { method?: string; body?: unknown } = {}, +): Promise<{ status: number; body: any }> { + const response = await fetch(`${baseUrl}${pathname}`, { + method: options.method ?? "GET", + headers: options.body === undefined ? undefined : { "Content-Type": "application/json" }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + const text = await response.text(); + + return { + status: response.status, + body: text ? JSON.parse(text) : null, + }; +} diff --git a/src/problem5/index.ts b/src/problem5/index.ts new file mode 100644 index 0000000000..824d55c681 --- /dev/null +++ b/src/problem5/index.ts @@ -0,0 +1,27 @@ +/** + * @file Problem 5 - A Crude Server + * @description Starts and exports a TypeScript ExpressJS CRUD API with a + * file-backed database for resource persistence. + * @author Paing Sett Kyaw + * @created 2026-05-15 + * @updated 2026-05-16 + */ + +import { createApp } from "./app"; +import { DEFAULT_DATABASE_FILE, DEFAULT_PORT } from "./constants"; +import { JsonResourceDatabase } from "./json-resource-database"; + +if (require.main === module) { + const port = Number(process.env.PORT ?? DEFAULT_PORT); + const databaseFile = process.env.DATABASE_FILE ?? DEFAULT_DATABASE_FILE; + const app = createApp(new JsonResourceDatabase(databaseFile)); + + app.listen(port, () => { + console.log(`Problem 5 server is running on http://localhost:${port}`); + console.log(`Database file: ${databaseFile}`); + }); +} + +export { createApp } from "./app"; +export { JsonResourceDatabase } from "./json-resource-database"; +export type { Resource, ResourceRepository } from "./types"; diff --git a/src/problem5/json-resource-database.ts b/src/problem5/json-resource-database.ts new file mode 100644 index 0000000000..8cd1738517 --- /dev/null +++ b/src/problem5/json-resource-database.ts @@ -0,0 +1,172 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { DEFAULT_DATABASE_FILE } from "./constants"; +import { HttpError } from "./errors"; +import { + type CreateResourceInput, + type DatabaseShape, + type Resource, + type ResourceFilters, + type ResourceRepository, + type UpdateResourceInput, +} from "./types"; +import { parseDatabase, resourceContains } from "./validation"; + +/** + * @description File-backed repository for resource records. It stores all + * resources in a JSON file and rewrites the file after each mutation. + */ +export class JsonResourceDatabase implements ResourceRepository { + /** + * @description Creates a JSON repository instance. + * @param databaseFile - Path to the JSON file used for persistence. + */ + public constructor(private readonly databaseFile = DEFAULT_DATABASE_FILE) {} + + /** + * @description Lists resources that match optional filters. + * @param filters - Category, status, tag, and search filters. + * @returns Matching resources from the database file. + */ + public async list(filters: ResourceFilters): Promise { + const { resources } = await this.read(); + const search = filters.search?.toLowerCase(); + + return resources.filter((resource) => { + const matchesCategory = + filters.category === undefined || resource.category === filters.category; + const matchesStatus = + filters.status === undefined || resource.status === filters.status; + const matchesTag = filters.tag === undefined || resource.tags.includes(filters.tag); + const matchesSearch = search === undefined || resourceContains(resource, search); + + return matchesCategory && matchesStatus && matchesTag && matchesSearch; + }); + } + + /** + * @description Finds one resource by id. + * @param id - Resource id. + * @returns The matching resource, or undefined when not found. + */ + public async findById(id: string): Promise { + const { resources } = await this.read(); + + return resources.find((resource) => resource.id === id); + } + + /** + * @description Creates and persists a new resource. + * @param input - Validated resource fields. + * @returns The created resource with id and timestamps. + */ + public async create(input: CreateResourceInput): Promise { + const database = await this.read(); + const timestamp = new Date().toISOString(); + const resource: Resource = { + id: randomUUID(), + ...input, + createdAt: timestamp, + updatedAt: timestamp, + }; + + await this.write({ resources: [...database.resources, resource] }); + + return resource; + } + + /** + * @description Updates an existing resource and persists the change. + * @param id - Resource id. + * @param input - Validated partial resource fields. + * @returns Updated resource, or undefined when not found. + */ + public async update( + id: string, + input: UpdateResourceInput, + ): Promise { + const database = await this.read(); + const existing = database.resources.find((resource) => resource.id === id); + + if (!existing) { + return undefined; + } + + const updated: Resource = { + ...existing, + ...input, + updatedAt: new Date().toISOString(), + }; + + await this.write({ + resources: database.resources.map((resource) => + resource.id === id ? updated : resource, + ), + }); + + return updated; + } + + /** + * @description Deletes a resource by id. + * @param id - Resource id. + * @returns True when a record was removed. + */ + public async delete(id: string): Promise { + const database = await this.read(); + const resources = database.resources.filter((resource) => resource.id !== id); + + if (resources.length === database.resources.length) { + return false; + } + + await this.write({ resources }); + + return true; + } + + /** + * @description Reads and validates the JSON database file. + * @returns Parsed database content, or an empty database when the file is missing. + */ + private async read(): Promise { + try { + const rawDatabase = await readFile(this.databaseFile, "utf8"); + const parsedDatabase = JSON.parse(rawDatabase) as unknown; + + return parseDatabase(parsedDatabase); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return { resources: [] }; + } + + if (error instanceof SyntaxError) { + throw new HttpError(500, "Database file contains invalid JSON"); + } + + throw error; + } + } + + /** + * @description Persists the full database using an atomic rename. + * @param database - Complete database state to write. + */ + private async write(database: DatabaseShape): Promise { + await mkdir(path.dirname(this.databaseFile), { recursive: true }); + + const temporaryFile = `${this.databaseFile}.${process.pid}.tmp`; + await writeFile(temporaryFile, `${JSON.stringify(database, null, 2)}\n`, "utf8"); + await rename(temporaryFile, this.databaseFile); + } +} + +/** + * @description Checks whether an unknown error has a Node-style error code. + * @param error - Unknown error thrown by filesystem APIs. + * @returns True when the error exposes a code property. + */ +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/src/problem5/types.ts b/src/problem5/types.ts new file mode 100644 index 0000000000..00018984fc --- /dev/null +++ b/src/problem5/types.ts @@ -0,0 +1,40 @@ +import { RESOURCE_STATUSES } from "./constants"; + +export type ResourceStatus = (typeof RESOURCE_STATUSES)[number]; + +export interface Resource { + id: string; + title: string; + description: string; + category: string; + status: ResourceStatus; + tags: string[]; + createdAt: string; + updatedAt: string; +} + +export interface DatabaseShape { + resources: Resource[]; +} + +export interface ResourceFilters { + category?: string; + status?: ResourceStatus; + search?: string; + tag?: string; +} + +export type CreateResourceInput = Pick< + Resource, + "title" | "description" | "category" | "status" | "tags" +>; + +export type UpdateResourceInput = Partial; + +export interface ResourceRepository { + list(filters: ResourceFilters): Promise; + findById(id: string): Promise; + create(input: CreateResourceInput): Promise; + update(id: string, input: UpdateResourceInput): Promise; + delete(id: string): Promise; +} diff --git a/src/problem5/validation.ts b/src/problem5/validation.ts new file mode 100644 index 0000000000..996276f1c5 --- /dev/null +++ b/src/problem5/validation.ts @@ -0,0 +1,266 @@ +import { type Request } from "express"; +import { RESOURCE_STATUSES } from "./constants"; +import { HttpError } from "./errors"; +import { + type CreateResourceInput, + type DatabaseShape, + type Resource, + type ResourceFilters, + type ResourceStatus, + type UpdateResourceInput, +} from "./types"; + +/** + * @description Parses and validates list-resource query filters. + * @param request - Express request containing query parameters. + * @returns Normalized filters for the repository. + */ +export function parseResourceFilters(request: Request): ResourceFilters { + const status = readOptionalQueryString(request, "status"); + + if (status !== undefined && !isResourceStatus(status)) { + throw new HttpError(400, "status must be active or archived"); + } + + return { + category: readOptionalQueryString(request, "category"), + status, + search: readOptionalQueryString(request, "search"), + tag: readOptionalQueryString(request, "tag"), + }; +} + +/** + * @description Parses and validates a create-resource request body. + * @param body - Raw JSON request body. + * @returns Validated input for creating a resource. + */ +export function parseCreateResourceInput(body: unknown): CreateResourceInput { + const payload = readObjectPayload(body); + + return { + title: readRequiredString(payload.title, "title"), + description: readOptionalString(payload.description, "description") ?? "", + category: readRequiredString(payload.category, "category"), + status: readOptionalStatus(payload.status) ?? "active", + tags: readTags(payload.tags), + }; +} + +/** + * @description Parses and validates an update-resource request body. + * @param body - Raw JSON request body. + * @returns Validated input containing at least one field to update. + */ +export function parseUpdateResourceInput(body: unknown): UpdateResourceInput { + const payload = readObjectPayload(body); + const input: UpdateResourceInput = {}; + + if (Object.hasOwn(payload, "title")) { + input.title = readRequiredString(payload.title, "title"); + } + + if (Object.hasOwn(payload, "description")) { + input.description = readOptionalString(payload.description, "description") ?? ""; + } + + if (Object.hasOwn(payload, "category")) { + input.category = readRequiredString(payload.category, "category"); + } + + if (Object.hasOwn(payload, "status")) { + input.status = readOptionalStatus(payload.status) ?? "active"; + } + + if (Object.hasOwn(payload, "tags")) { + input.tags = readTags(payload.tags); + } + + if (Object.keys(input).length === 0) { + throw new HttpError(400, "At least one field must be provided for update"); + } + + return input; +} + +/** + * @description Reads and validates a route resource id. + * @param request - Express request containing route parameters. + * @returns Trimmed route id. + */ +export function readRouteId(request: Request): string { + const id = request.params.id; + + if (typeof id !== "string" || id.trim() === "") { + throw new HttpError(400, "Resource id is required"); + } + + return id.trim(); +} + +/** + * @description Parses the JSON database object loaded from disk. + * @param value - Raw parsed JSON value. + * @returns Safe database object used by the repository. + */ +export function parseDatabase(value: unknown): DatabaseShape { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new HttpError(500, "Database root must be an object"); + } + + const resources = (value as Partial).resources; + + if (!Array.isArray(resources)) { + return { resources: [] }; + } + + return { resources: resources.filter(isResource) }; +} + +/** + * @description Checks whether a resource contains the lower-case search term. + * @param resource - Resource to search. + * @param search - Lower-case search string. + * @returns True when any searchable field contains the term. + */ +export function resourceContains(resource: Resource, search: string): boolean { + return [resource.title, resource.description, resource.category, ...resource.tags] + .join(" ") + .toLowerCase() + .includes(search); +} + +/** + * @description Checks whether a value is an allowed resource status. + * @param value - Raw status value. + * @returns True when the value is active or archived. + */ +export function isResourceStatus(value: unknown): value is ResourceStatus { + return RESOURCE_STATUSES.includes(value as ResourceStatus); +} + +/** + * @description Ensures a request body is a JSON object. + * @param value - Raw request body value. + * @returns Object payload with unknown field values. + */ +function readObjectPayload(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new HttpError(400, "Request body must be a JSON object"); + } + + return value as Record; +} + +/** + * @description Reads one optional string query parameter. + * @param request - Express request containing query parameters. + * @param key - Query parameter name. + * @returns Trimmed query value, or undefined when omitted. + */ +function readOptionalQueryString(request: Request, key: string): string | undefined { + const value = request.query[key]; + + if (value === undefined || value === null || value === "") { + return undefined; + } + + if (typeof value !== "string") { + throw new HttpError(400, `${key} must be a single string value`); + } + + return value.trim() || undefined; +} + +/** + * @description Validates a required non-empty string field. + * @param value - Raw field value. + * @param field - Field name used in validation errors. + * @returns Trimmed string value. + */ +function readRequiredString(value: unknown, field: string): string { + const normalized = readOptionalString(value, field); + + if (!normalized) { + throw new HttpError(400, `${field} is required and must be a non-empty string`); + } + + return normalized; +} + +/** + * @description Validates an optional string field. + * @param value - Raw field value. + * @param field - Field name used in validation errors. + * @returns Trimmed string value, or undefined when absent. + */ +function readOptionalString(value: unknown, field: string): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + + if (typeof value !== "string") { + throw new HttpError(400, `${field} must be a string`); + } + + return value.trim(); +} + +/** + * @description Validates an optional status field. + * @param value - Raw status value. + * @returns Valid status, or undefined when omitted. + */ +function readOptionalStatus(value: unknown): ResourceStatus | undefined { + if (value === undefined || value === null || value === "") { + return undefined; + } + + if (!isResourceStatus(value)) { + throw new HttpError(400, "status must be active or archived"); + } + + return value; +} + +/** + * @description Validates an optional tags array. + * @param value - Raw tags value. + * @returns Trimmed tag strings. + */ +function readTags(value: unknown): string[] { + if (value === undefined || value === null) { + return []; + } + + if (!Array.isArray(value)) { + throw new HttpError(400, "tags must be an array of strings"); + } + + return value.map((tag) => readRequiredString(tag, "tag")); +} + +/** + * @description Checks whether an unknown value has the persisted resource shape. + * @param value - Raw database array item. + * @returns True when the value is a valid resource. + */ +function isResource(value: unknown): value is Resource { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + + const candidate = value as Partial; + + return ( + typeof candidate.id === "string" && + typeof candidate.title === "string" && + typeof candidate.description === "string" && + typeof candidate.category === "string" && + isResourceStatus(candidate.status) && + Array.isArray(candidate.tags) && + candidate.tags.every((tag) => typeof tag === "string") && + typeof candidate.createdAt === "string" && + typeof candidate.updatedAt === "string" + ); +} diff --git a/src/problem6/.keep b/src/problem6/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/problem6/README.md b/src/problem6/README.md new file mode 100644 index 0000000000..647d2fa79c --- /dev/null +++ b/src/problem6/README.md @@ -0,0 +1,621 @@ +# Problem 6 - Scoreboard API Architecture + +## Purpose + +This document specifies the backend module responsible for recording completed +user actions, increasing user scores, returning the top 10 scoreboard, and +publishing live scoreboard updates to connected clients. + +The module belongs to the application server API. The website calls this module +after a user completes an action. The module must verify the user, prevent +unauthorized or duplicated score updates, persist the score change safely, and +notify live scoreboard clients. + +## Scope + +This specification covers: + +- API endpoint contract for completing a score-increasing action. +- Authentication and authorization requirements. +- Validation and anti-abuse rules. +- Database transaction behavior. +- Top 10 leaderboard query behavior. +- Live update publishing through WebSocket or Server-Sent Events. +- Failure behavior and improvement notes. + +This specification does not define the product-specific action itself. The +module only needs enough action metadata to validate that the action is valid, +allowed, and not already counted. + +## Recommended Technology + +The backend implementation should use: + +- NestJS for the API module structure, guards, validation pipes, services, and + WebSocket gateway. +- PostgreSQL as the source of truth for users, scores, completed actions, and + leaderboard queries. +- TypeORM, Prisma, or another NestJS-compatible PostgreSQL ORM. Prisma is a good + choice for strict schema management, while TypeORM is also acceptable if the + existing backend already uses it. +- `class-validator` and `class-transformer` for request DTO validation. +- Passport JWT or an existing NestJS auth guard for user authentication. +- `@nestjs/throttler` or API gateway rate limiting for anti-abuse protection. +- Redis is optional for caching, distributed rate limits, and WebSocket scaling, + but PostgreSQL must remain the source of truth. + +## Backend Architecture Design + +The backend should be designed as a NestJS `ScoresModule` inside the application +server. PostgreSQL is the authoritative data store. Redis is optional, but it is +recommended for distributed rate limiting, leaderboard cache, and WebSocket +fan-out when the API runs on more than one instance. + +```mermaid +flowchart LR + Browser[Website / Scoreboard UI] + AuthGuard[JWT Auth Guard] + RateGuard[Rate Limit Guard] + Controller[ScoresController] + ScoresService[ScoresService] + LeaderboardService[LeaderboardService] + EventsGateway[ScoreEventsGateway] + ActionRepo[ScoreAction Repository] + CompletedRepo[CompletedAction Repository] + ScoreRepo[UserScore Repository] + Postgres[(PostgreSQL)] + Redis[(Redis optional)] + Clients[Connected Scoreboard Clients] + + Browser -->|POST /scores/actions/complete| AuthGuard + AuthGuard --> RateGuard + RateGuard --> Controller + Controller --> ScoresService + ScoresService --> ActionRepo + ScoresService --> CompletedRepo + ScoresService --> ScoreRepo + ScoresService --> LeaderboardService + ActionRepo --> Postgres + CompletedRepo --> Postgres + ScoreRepo --> Postgres + LeaderboardService --> Postgres + LeaderboardService -. cache top 10 .-> Redis + ScoresService -->|after commit| EventsGateway + EventsGateway -. publish / subscribe .-> Redis + EventsGateway -->|leaderboard.updated| Clients +``` + +### Backend Layers + +| Layer | Responsibility | +| --- | --- | +| `ScoresController` | Exposes HTTP endpoints and maps request DTOs to service calls. | +| `JwtAuthGuard` | Verifies the user identity before any score-changing logic runs. | +| `ScoreRateLimitGuard` | Blocks excessive requests by user ID, IP address, and action ID. | +| `ScoresService` | Owns the action completion workflow and PostgreSQL transaction. | +| `LeaderboardService` | Reads the top 10 leaderboard and optional user rank. | +| Repositories or ORM delegates | Encapsulate PostgreSQL access for score actions, completed actions, and user scores. | +| `ScoreEventsGateway` | Publishes live leaderboard updates through WebSocket or SSE. | +| PostgreSQL | Source of truth for actions, completed action records, scores, and leaderboard queries. | +| Redis | Optional cache, distributed throttle store, and pub/sub adapter for scaled realtime updates. | + +### Backend Request Lifecycle + +1. `ScoresController.completeAction()` receives `POST /scores/actions/complete`. +2. `JwtAuthGuard` validates the JWT and attaches the authenticated user to the + request context. +3. `ScoreRateLimitGuard` checks user/IP/action request frequency. +4. `ValidationPipe` validates `CompleteScoreActionDto` and removes unknown + fields. +5. `ScoresService.completeAction()` starts a PostgreSQL transaction. +6. The service loads the server-side `score_actions` record and rejects inactive + or unauthorized actions. +7. The service inserts `completed_score_actions` with unique constraints to + prevent duplicate scoring. +8. The service increments `user_scores.score` by the server-owned + `score_delta`. +9. The service reads the updated user score and top 10 leaderboard. +10. After transaction commit, `ScoreEventsGateway` publishes + `leaderboard.updated` to connected clients. +11. The API returns the updated user score and leaderboard to the requesting + website. + +### Important Design Decisions + +- The client never sends score delta or final score. It only sends the completed + action identity and completion event identity. +- Duplicate prevention must be enforced by PostgreSQL unique constraints, not + only by application code. +- Score update and completed-action insert must happen in the same transaction. +- Live events must be emitted only after successful commit. +- The leaderboard payload must contain only public display fields. +- If the product later has multiple application server instances, the WebSocket + gateway needs a Redis adapter or message broker so every instance receives the + same leaderboard update. + +## Execution Flow + +```mermaid +sequenceDiagram + autonumber + + participant User + participant Web as Website / Scoreboard UI + participant API as Application Server API + participant Auth as Auth Service + participant DB as Database + participant Live as WebSocket / SSE Channel + participant Clients as Connected Scoreboard Clients + + User->>Web: Complete an action + Web->>API: POST /scores/actions/complete + Note over Web,API: Request includes JWT/session token
and action completion payload + + API->>Auth: Validate user identity and authorization + Auth-->>API: User is authenticated + + API->>API: Validate action rules + Note over API: Check action exists
Check user is allowed
Check action was not already counted
Apply rate limits / anti-abuse rules + + API->>DB: Start transaction + API->>DB: Record completed action + API->>DB: Increment user's score + API->>DB: Query updated top 10 scores + DB-->>API: Updated leaderboard + API->>DB: Commit transaction + + API-->>Web: Return updated user score and leaderboard + + API->>Live: Publish leaderboard update + Live->>Clients: Push latest top 10 scores + + Clients->>Clients: Re-render live scoreboard +``` + +## API Contract + +Recommended NestJS route ownership: + +```text +ScoresModule + ScoresController + ScoresService + LeaderboardService + ScoreEventsGateway + ScoreActionRepository +``` + +The route should be protected by an auth guard before entering controller +business logic. + +### Complete Action + +`POST /scores/actions/complete` + +Records a completed user action and increments the authenticated user's score. + +### Headers + +```http +Authorization: Bearer +Content-Type: application/json +Idempotency-Key: +``` + +`Idempotency-Key` is recommended. If the same request is retried because of a +network issue, the API should return the original result instead of increasing +the score again. + +### Request Body + +```json +{ + "actionId": "daily-login", + "completionId": "client-or-system-generated-unique-id", + "completedAt": "2026-05-15T15:30:00.000Z", + "metadata": { + "source": "web" + } +} +``` + +### Request Fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `actionId` | string | Yes | Identifier of the action that was completed. | +| `completionId` | string | Yes | Unique identifier for this completion event. Used to prevent duplicates. | +| `completedAt` | ISO datetime | Yes | Time the action was completed on the client or source system. | +| `metadata` | object | No | Optional non-sensitive details for validation, auditing, or analytics. | + +The API must not trust score values from the client. The score increment must be +looked up from server-side action configuration. + +### NestJS DTO + +```ts +import { IsDateString, IsObject, IsOptional, IsString, MaxLength } from "class-validator"; + +export class CompleteScoreActionDto { + @IsString() + @MaxLength(100) + actionId: string; + + @IsString() + @MaxLength(150) + completionId: string; + + @IsDateString() + completedAt: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} +``` + +### Success Response + +`200 OK` + +```json +{ + "userScore": { + "userId": "user_123", + "score": 120, + "rank": 8 + }, + "leaderboard": [ + { + "rank": 1, + "userId": "user_001", + "displayName": "Alice", + "score": 320 + } + ] +} +``` + +The leaderboard array must contain at most 10 records ordered by score +descending. Ties should use a stable secondary order such as earliest score +update time, then user ID. + +## Validation Rules + +The API must validate the request in this order: + +1. Authenticate the JWT or session token. +2. Confirm the authenticated user is allowed to complete the requested action. +3. Confirm `actionId` exists and is active. +4. Confirm the action has not already been counted for this user and completion + event. +5. Confirm the action-specific frequency rule is satisfied, for example once + per day or once per campaign. +6. Apply rate limits based on user ID, IP address, and action ID. +7. Reject suspicious metadata or invalid timestamps. + +The server should return deterministic responses for duplicate requests. A +previously accepted duplicate should not increment score again. + +In NestJS, the authentication check should be handled by a guard, while request +shape validation should be handled by a global `ValidationPipe` with +`whitelist: true` and `forbidNonWhitelisted: true`. + +## Persistence Model + +Recommended PostgreSQL tables: + +### `users` + +| Column | Description | +| --- | --- | +| `id` | Primary user identifier. | +| `display_name` | Public display name shown on the scoreboard. | +| `created_at` | User creation time. | + +### `score_actions` + +| Column | Description | +| --- | --- | +| `id` | Action identifier. | +| `score_delta` | Number of points awarded when completed. | +| `is_active` | Whether the action can currently award score. | +| `frequency_rule` | Rule used to prevent repeated scoring. | + +### `user_scores` + +| Column | Description | +| --- | --- | +| `user_id` | User identifier. Unique. | +| `score` | Current total score. | +| `updated_at` | Last score update time. | + +### `completed_score_actions` + +| Column | Description | +| --- | --- | +| `id` | Internal record ID. | +| `user_id` | User who completed the action. | +| `action_id` | Completed action. | +| `completion_id` | Unique completion event ID. | +| `idempotency_key` | Request idempotency key. | +| `score_delta` | Score awarded for this completion. | +| `created_at` | Server-side record creation time. | + +Required uniqueness constraints: + +- Unique `(user_id, action_id, completion_id)`. +- Unique `(user_id, idempotency_key)` when an idempotency key is provided. +- Additional unique indexes for frequency rules, for example + `(user_id, action_id, action_date)` for once-per-day actions. + +Example PostgreSQL DDL: + +```sql +CREATE TABLE score_actions ( + id VARCHAR(100) PRIMARY KEY, + score_delta INTEGER NOT NULL CHECK (score_delta > 0), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + frequency_rule VARCHAR(50) NOT NULL DEFAULT 'once', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE user_scores ( + user_id UUID PRIMARY KEY REFERENCES users(id), + score INTEGER NOT NULL DEFAULT 0 CHECK (score >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE completed_score_actions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + action_id VARCHAR(100) NOT NULL REFERENCES score_actions(id), + completion_id VARCHAR(150) NOT NULL, + idempotency_key VARCHAR(150), + score_delta INTEGER NOT NULL CHECK (score_delta > 0), + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, action_id, completion_id), + UNIQUE (user_id, idempotency_key) +); + +CREATE INDEX idx_user_scores_leaderboard + ON user_scores (score DESC, updated_at ASC, user_id ASC); +``` + +If `idempotency_key` is nullable, PostgreSQL allows multiple null values in a +unique constraint. That is acceptable because the duplicate protection still +comes from `(user_id, action_id, completion_id)`. + +## Transaction Requirements + +The completed action record, user score increment, and leaderboard read must be +handled in a single database transaction. + +Expected transaction steps: + +1. Lock or upsert the user's score row. +2. Insert the completed action record. +3. If the insert violates a uniqueness rule, return the previous accepted + result or a duplicate response without incrementing score. +4. Increment the user's score by the server-side `score_delta`. +5. Query the updated top 10 leaderboard. +6. Commit the transaction. + +The live update must be published only after the transaction commits. This +prevents clients from seeing a leaderboard update that was later rolled back. + +Recommended NestJS service behavior: + +```ts +async completeAction(userId: string, dto: CompleteScoreActionDto, idempotencyKey?: string) { + const result = await this.database.transaction(async (tx) => { + const action = await tx.scoreAction.findActive(dto.actionId); + this.assertActionCanBeCompleted(userId, action, dto); + + await tx.completedScoreAction.insert({ + userId, + actionId: dto.actionId, + completionId: dto.completionId, + idempotencyKey, + scoreDelta: action.scoreDelta, + metadata: dto.metadata, + }); + + await tx.userScore.increment(userId, action.scoreDelta); + + return { + userScore: await tx.userScore.findUserScoreWithRank(userId), + leaderboard: await tx.userScore.findTop10(), + }; + }); + + await this.scoreEventsGateway.publishLeaderboardUpdated(result.leaderboard); + + return result; +} +``` + +The real implementation must catch unique-constraint errors and convert them +into either the previous idempotent response or a `409 Conflict` response. + +## Leaderboard Query + +The top 10 query should be optimized with an index on: + +```sql +score DESC, updated_at ASC, user_id ASC +``` + +The API may calculate the requesting user's rank separately if the user is not +in the top 10. This is useful for returning the user's updated score while still +keeping the public scoreboard limited to 10 rows. + +Example top 10 query: + +```sql +SELECT + ROW_NUMBER() OVER (ORDER BY us.score DESC, us.updated_at ASC, us.user_id ASC) AS rank, + us.user_id, + u.display_name, + us.score +FROM user_scores us +JOIN users u ON u.id = us.user_id +ORDER BY us.score DESC, us.updated_at ASC, us.user_id ASC +LIMIT 10; +``` + +## Live Updates + +The live channel may be implemented with WebSocket or Server-Sent Events. + +Recommended event name: + +`leaderboard.updated` + +Recommended event payload: + +```json +{ + "leaderboard": [ + { + "rank": 1, + "userId": "user_001", + "displayName": "Alice", + "score": 320 + } + ], + "updatedAt": "2026-05-15T15:30:01.000Z" +} +``` + +Connected scoreboard clients should replace their current top 10 list with the +latest payload. The API or live service should avoid publishing sensitive user +data. + +Recommended NestJS implementation: + +- Use a `ScoreEventsGateway` if the application already uses WebSocket. +- Use SSE if the scoreboard only needs one-way server-to-client updates. +- Emit updates after the database transaction commits. +- If multiple API instances are deployed, use Redis Pub/Sub or a message broker + so every instance can broadcast the same leaderboard update to its connected + clients. + +## Security Requirements + +- Require authentication for score-changing endpoints. +- Never accept score deltas or final scores from the client. +- Use server-side action configuration to determine score changes. +- Store completed action records for auditability. +- Apply rate limits and duplicate detection before awarding score. +- Log rejected requests with enough context for abuse investigation. +- Do not expose private user information in leaderboard responses. +- Validate all request fields with a strict schema. +- Use HTTPS in production. + +## Error Responses + +| Status | Reason | +| --- | --- | +| `400 Bad Request` | Missing or invalid request fields. | +| `401 Unauthorized` | Missing or invalid authentication token. | +| `403 Forbidden` | User is authenticated but not allowed to complete the action. | +| `404 Not Found` | Action does not exist or is not visible to this user. | +| `409 Conflict` | Action completion was already counted and cannot be repeated. | +| `429 Too Many Requests` | Rate limit or anti-abuse rule was triggered. | +| `500 Internal Server Error` | Unexpected server failure. | + +For duplicate requests caused by retry behavior, prefer returning the original +successful response when an idempotency key matches. + +Recommended NestJS exception mapping: + +| Exception | Status | +| --- | --- | +| `BadRequestException` | `400 Bad Request` | +| `UnauthorizedException` | `401 Unauthorized` | +| `ForbiddenException` | `403 Forbidden` | +| `NotFoundException` | `404 Not Found` | +| `ConflictException` | `409 Conflict` | +| `TooManyRequestsException` | `429 Too Many Requests` | + +## Observability + +The module should emit structured logs and metrics for: + +- Accepted score updates. +- Duplicate completion attempts. +- Authorization failures. +- Rate-limited requests. +- Transaction failures. +- Live update publish failures. +- Leaderboard query latency. + +Important metrics: + +- `score_action_complete_total` +- `score_action_duplicate_total` +- `score_action_rejected_total` +- `leaderboard_publish_total` +- `leaderboard_publish_failed_total` +- `leaderboard_query_duration_ms` + +## Additional Comments for Improvement + +These improvements are not required for the first implementation, but they +should be considered before production release: + +1. Use the outbox pattern for live update publishing. Store a + `leaderboard.updated` event inside the same PostgreSQL transaction, then let a + background worker publish it to WebSocket or SSE clients. This prevents lost + live updates when the API commits successfully but the publish step fails. +2. Add Redis for distributed rate limiting. In-memory NestJS throttling is not + enough when the API runs on multiple instances. +3. Cache the top 10 leaderboard in Redis for high traffic. PostgreSQL should + remain the source of truth, and cache invalidation should happen after score + updates. +4. Add admin audit tooling for suspicious score changes, duplicate attempts, and + rate-limit violations. +5. Add a score rebuild job that recalculates `user_scores` from + `completed_score_actions`. This helps recover from scoring bugs or manual + data fixes. +6. Sign action-completion events if actions are completed by another backend + service. The API should verify the signature before awarding score. +7. Add integration tests that prove duplicate requests, unauthorized requests, + and concurrent requests cannot increase the score incorrectly. +8. Add a rank lookup endpoint later, for example `GET /scores/me`, so users + outside the top 10 can still see their own score and rank. +9. Use database migrations for all tables and indexes. The backend team should + not create production tables manually. +10. Avoid putting private user data in live events. The scoreboard should expose + only public display fields required by the UI. + +## Suggested NestJS File Structure + +```text +src/scores/ + dto/ + complete-score-action.dto.ts + entities/ + completed-score-action.entity.ts + score-action.entity.ts + user-score.entity.ts + scores.controller.ts + scores.module.ts + scores.service.ts + leaderboard.service.ts + score-events.gateway.ts + score-rate-limit.guard.ts +``` + +The exact ORM entity filenames can change depending on whether the backend team +chooses Prisma or TypeORM, but the module boundaries should stay the same: + +- `ScoresController` handles HTTP request and response mapping. +- `ScoresService` owns the transaction and score update workflow. +- `LeaderboardService` owns top 10 and rank queries. +- `ScoreEventsGateway` owns WebSocket or SSE publishing. +- Guards and DTOs own authentication, authorization, throttling, and request + validation. diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..b7ea06cf9a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}