diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..718a0636a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +data/*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000000..c6fa2f4558 --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# 99Tech Backend Assessment + +This repository contains my backend assessment solutions for 99Tech. + +## Problem 4 + +File: `src/problem4.ts` + +The task is to provide three unique TypeScript implementations of: + +```ts +function sum_to_n(n: number): number +``` + +For example: + +```ts +sum_to_n(5) === 1 + 2 + 3 + 4 + 5 === 15 +``` + +### Assumption + +The prompt says `n` can be any integer, but only defines the positive case. +For `n <= 0`, I interpret "sum to n" as walking from `1` down to `n`. + +Example: + +```ts +sum_to_n(-3) === 1 + 0 + (-1) + (-2) + (-3) === -5 +``` + +The prompt also states that the result will always be less than +`Number.MAX_SAFE_INTEGER`. + +### Implementations + +- `sum_to_n_a`: arithmetic formula, `O(1)` time and `O(1)` space. +- `sum_to_n_b`: iterative loop, `O(|n|)` time and `O(1)` space. +- `sum_to_n_c`: recursion, `O(|n|)` time and `O(|n|)` space. + +### Run + +```bash +npm install +npm run build +npm run problem4 +``` + +## Problem 5 + +File: `src/problem5/server.ts` + +The task is to develop an ExpressJS backend server with TypeScript, CRUD APIs, +basic filters, and simple data persistence. + +This solution uses a `Book` resource and stores data in `data/books.json`. +The JSON file acts as a simple file-based database, so data remains after the +server restarts. + +### Book Resource + +```ts +type Book = { + id: string; + title: string; + author: string; + status: "available" | "borrowed" | "archived"; + description?: string; + createdAt: string; + updatedAt: string; +}; +``` + +### Run Server + +```bash +npm install +npm run dev +``` + +The server starts on: + +```txt +http://localhost:3000 +``` + +You can change the port with: + +```bash +PORT=4000 npm run dev +``` + +### API Endpoints + +Health check: + +```bash +curl http://localhost:3000/health +``` + +Create a book: + +```bash +curl -X POST http://localhost:3000/books \ + -H "Content-Type: application/json" \ + -d '{"title":"Clean Code","author":"Robert C. Martin","status":"available","description":"A book about writing readable code."}' +``` + +List books: + +```bash +curl "http://localhost:3000/books" +``` + +List books with filters: + +```bash +curl "http://localhost:3000/books?status=available&author=robert&q=clean&limit=10&offset=0" +``` + +Get book details: + +```bash +curl http://localhost:3000/books/ +``` + +Update a book: + +```bash +curl -X PATCH http://localhost:3000/books/ \ + -H "Content-Type: application/json" \ + -d '{"status":"borrowed"}' +``` + +Delete a book: + +```bash +curl -X DELETE http://localhost:3000/books/ +``` + +### Build And Run Compiled Code + +```bash +npm run build +npm start +``` + +## Problem 6 + +File: `problem6/README.md` + +The task is to write an architecture specification for a live scoreboard module. +The documentation includes API contracts, data model, security requirements, +execution flow, realtime update strategy, and improvement notes. diff --git a/data/books.json b/data/books.json new file mode 100644 index 0000000000..825ace245a --- /dev/null +++ b/data/books.json @@ -0,0 +1,13 @@ +{ + "books": [ + { + "id": "846c1e8d-dc2e-45ad-b444-274f03df7aa2", + "title": "Clean Code", + "author": "Robert C. Martin", + "status": "available", + "description": "A book about writing readable code.", + "createdAt": "2026-04-17T07:55:08.185Z", + "updatedAt": "2026-04-17T07:55:08.185Z" + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..0af1a56669 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1159 @@ +{ + "name": "99tech-backend-assessment", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "99tech-backend-assessment", + "version": "1.0.0", + "dependencies": { + "express": "^4.21.2" + }, + "devDependencies": { + "@types/express": "^4.17.25", + "@types/node": "^22.10.2", + "ts-node": "^10.9.2", + "typescript": "^5.7.2" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "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": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "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/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "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": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "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": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "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": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "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.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "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": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "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/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": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "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.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "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": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/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/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/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "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/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "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/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..a16e100279 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "99tech-backend-assessment", + "version": "1.0.0", + "private": true, + "description": "Backend assessment solutions for 99Tech.", + "scripts": { + "build": "tsc", + "start": "node dist/problem5/server.js", + "dev": "ts-node src/problem5/server.ts", + "problem4": "ts-node src/problem4/index.ts", + "start:problem4": "node dist/problem4/index.js" + }, + "dependencies": { + "express": "^4.21.2" + }, + "devDependencies": { + "@types/express": "^4.17.25", + "@types/node": "^22.10.2", + "ts-node": "^10.9.2", + "typescript": "^5.7.2" + } +} diff --git a/src/problem4/index.ts b/src/problem4/index.ts new file mode 100644 index 0000000000..6705a3aea9 --- /dev/null +++ b/src/problem4/index.ts @@ -0,0 +1,54 @@ +/* + * Problem 4: Three ways to implement sum_to_n(n). + * + * Assumption: + * The prompt says n can be any integer, but defines the operation with + * sum_to_n(5) === 1 + 2 + 3 + 4 + 5. + * + * For n <= 0, I interpret "sum to n" as walking from 1 down to n. + * Example: sum_to_n(-3) === 1 + 0 + (-1) + (-2) + (-3) === -5. + * + * The prompt also guarantees the result is less than Number.MAX_SAFE_INTEGER. + */ + +export function sum_to_n_a(n: number): number { + if (n >= 1) { + return (n * (n + 1)) / 2; + } + const absoluteN = Math.abs(n); + return 1 - (absoluteN * (absoluteN + 1)) / 2; +} + +export function sum_to_n_b(n: number): number { + let sum = 0; + const step = n >= 1 ? 1 : -1; + + for ( + let current = 1; + step === 1 ? current <= n : current >= n; + current += step + ) { + sum += current; + } + + return sum; +} + +export function sum_to_n_c(n: number): number { + const sumRange = (start: number, end: number): number => { + if (start === end) { + return start; + } + + const middle = Math.floor((start + end) / 2); + return sumRange(start, middle) + sumRange(middle + 1, end); + }; + + return n >= 1 ? sumRange(1, n) : sumRange(n, 1); +} + +const sampleInput = 5; + +console.log("sum_to_n_a:", sum_to_n_a(sampleInput)); +console.log("sum_to_n_b:", sum_to_n_b(sampleInput)); +console.log("sum_to_n_c:", sum_to_n_c(sampleInput)); diff --git a/src/problem5/bookService.ts b/src/problem5/bookService.ts new file mode 100644 index 0000000000..b42624bc4a --- /dev/null +++ b/src/problem5/bookService.ts @@ -0,0 +1,200 @@ +import { randomUUID } from "node:crypto"; +import { readDatabase, writeDatabase } from "./database"; +import { Book, BookFilters, BookStatus, CreateBookInput, UpdateBookInput } from "./types"; + +const validStatuses: BookStatus[] = ["available", "borrowed", "archived"]; + +function isBookStatus(value: unknown): value is BookStatus { + return typeof value === "string" && validStatuses.includes(value as BookStatus); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function normalizeText(value: string): string { + return value.trim(); +} + +function parsePositiveInteger(value: unknown, fallback: number): number { + if (typeof value !== "string") { + return fallback; + } + + const parsedValue = Number.parseInt(value, 10); + return Number.isInteger(parsedValue) && parsedValue >= 0 ? parsedValue : fallback; +} + +export function parseBookFilters(query: Record): BookFilters { + return { + status: isBookStatus(query.status) ? query.status : undefined, + author: isNonEmptyString(query.author) ? normalizeText(query.author) : undefined, + q: isNonEmptyString(query.q) ? normalizeText(query.q) : undefined, + limit: parsePositiveInteger(query.limit, 20), + offset: parsePositiveInteger(query.offset, 0), + }; +} + +export function parseCreateBookInput(body: unknown): CreateBookInput { + if (typeof body !== "object" || body === null) { + throw new Error("Request body must be an object."); + } + + const input = body as Record; + + if (!isNonEmptyString(input.title)) { + throw new Error("title is required."); + } + + if (!isNonEmptyString(input.author)) { + throw new Error("author is required."); + } + + if (input.status !== undefined && !isBookStatus(input.status)) { + throw new Error("status must be one of: available, borrowed, archived."); + } + + if (input.description !== undefined && typeof input.description !== "string") { + throw new Error("description must be a string."); + } + + return { + title: normalizeText(input.title), + author: normalizeText(input.author), + status: input.status ?? "available", + description: typeof input.description === "string" ? normalizeText(input.description) : undefined, + }; +} + +export function parseUpdateBookInput(body: unknown): UpdateBookInput { + if (typeof body !== "object" || body === null) { + throw new Error("Request body must be an object."); + } + + const input = body as Record; + const update: UpdateBookInput = {}; + + if (input.title !== undefined) { + if (!isNonEmptyString(input.title)) { + throw new Error("title must be a non-empty string."); + } + + update.title = normalizeText(input.title); + } + + if (input.author !== undefined) { + if (!isNonEmptyString(input.author)) { + throw new Error("author must be a non-empty string."); + } + + update.author = normalizeText(input.author); + } + + if (input.status !== undefined) { + if (!isBookStatus(input.status)) { + throw new Error("status must be one of: available, borrowed, archived."); + } + + update.status = input.status; + } + + if (input.description !== undefined) { + if (typeof input.description !== "string") { + throw new Error("description must be a string."); + } + + update.description = normalizeText(input.description); + } + + if (Object.keys(update).length === 0) { + throw new Error("At least one field is required to update."); + } + + return update; +} + +export async function createBook(input: CreateBookInput): Promise { + const database = await readDatabase(); + const now = new Date().toISOString(); + + const book: Book = { + id: randomUUID(), + title: input.title, + author: input.author, + status: input.status ?? "available", + description: input.description, + createdAt: now, + updatedAt: now, + }; + + database.books.push(book); + await writeDatabase(database); + + return book; +} + +export async function listBooks(filters: BookFilters): Promise<{ items: Book[]; total: number }> { + const database = await readDatabase(); + const searchText = filters.q?.toLowerCase(); + const author = filters.author?.toLowerCase(); + + const filteredBooks = database.books.filter((book) => { + const matchesStatus = filters.status === undefined || book.status === filters.status; + const matchesAuthor = author === undefined || book.author.toLowerCase().includes(author); + const matchesSearch = + searchText === undefined || + book.title.toLowerCase().includes(searchText) || + book.author.toLowerCase().includes(searchText) || + book.description?.toLowerCase().includes(searchText); + + return matchesStatus && matchesAuthor && matchesSearch; + }); + + const offset = filters.offset ?? 0; + const limit = filters.limit ?? 20; + + return { + items: filteredBooks.slice(offset, offset + limit), + total: filteredBooks.length, + }; +} + +export async function getBookById(id: string): Promise { + const database = await readDatabase(); + return database.books.find((book) => book.id === id); +} + +export async function updateBook(id: string, input: UpdateBookInput): Promise { + const database = await readDatabase(); + const bookIndex = database.books.findIndex((book) => book.id === id); + + if (bookIndex === -1) { + return undefined; + } + + const currentBook = database.books[bookIndex]; + const updatedBook: Book = { + ...currentBook, + ...input, + updatedAt: new Date().toISOString(), + }; + + database.books[bookIndex] = updatedBook; + await writeDatabase(database); + + return updatedBook; +} + +export async function deleteBook(id: string): Promise { + const database = await readDatabase(); + const bookIndex = database.books.findIndex((book) => book.id === id); + + if (bookIndex === -1) { + return false; + } + + database.books.splice(bookIndex, 1); + await writeDatabase(database); + + return true; +} diff --git a/src/problem5/database.ts b/src/problem5/database.ts new file mode 100644 index 0000000000..8f9f64d749 --- /dev/null +++ b/src/problem5/database.ts @@ -0,0 +1,39 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { Book } from "./types"; + +type DatabaseContent = { + books: Book[]; +}; + +const databasePath = path.join(process.cwd(), "data", "books.json"); +const temporaryDatabasePath = path.join(process.cwd(), "data", "books.json.tmp"); + +const emptyDatabase: DatabaseContent = { + books: [], +}; + +async function ensureDatabaseFileExists(): Promise { + await mkdir(path.dirname(databasePath), { recursive: true }); + + try { + await readFile(databasePath, "utf8"); + } catch { + await writeDatabase(emptyDatabase); + } +} + +export async function readDatabase(): Promise { + await ensureDatabaseFileExists(); + + const fileContent = await readFile(databasePath, "utf8"); + return JSON.parse(fileContent) as DatabaseContent; +} + +export async function writeDatabase(content: DatabaseContent): Promise { + await mkdir(path.dirname(databasePath), { recursive: true }); + + const json = JSON.stringify(content, null, 2); + await writeFile(temporaryDatabasePath, json); + await rename(temporaryDatabasePath, databasePath); +} diff --git a/src/problem5/postman_collection/postman_collection.json b/src/problem5/postman_collection/postman_collection.json new file mode 100644 index 0000000000..6716703874 --- /dev/null +++ b/src/problem5/postman_collection/postman_collection.json @@ -0,0 +1,189 @@ +{ + "info": { + "_postman_id": "3c5bc72b-22b0-45c0-b241-b4bf6e722b2f", + "name": "99Tech Problem 5 - Book API", + "description": "Postman collection for the Problem 5 Express book management API.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:3000" + }, + { + "key": "bookId", + "value": "846c1e8d-dc2e-45ad-b444-274f03df7aa2" + } + ], + "item": [ + { + "name": "Health Check", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/health", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "health" + ] + }, + "description": "Check whether the server is running." + }, + "response": [] + }, + { + "name": "Create Book", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Clean Code\",\n \"author\": \"Robert C. Martin\",\n \"status\": \"available\",\n \"description\": \"A book about writing readable code.\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/books", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "books" + ] + }, + "description": "Create a new book." + }, + "response": [] + }, + { + "name": "List Books", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/books", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "books" + ] + }, + "description": "List books without filters." + }, + "response": [] + }, + { + "name": "List Books With Filters", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/books?status=available&author=robert&q=clean&limit=10&offset=0", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "books" + ], + "query": [ + { + "key": "status", + "value": "available" + }, + { + "key": "author", + "value": "robert" + }, + { + "key": "q", + "value": "clean" + }, + { + "key": "limit", + "value": "10" + }, + { + "key": "offset", + "value": "0" + } + ] + }, + "description": "List books with status, author, search, pagination filters." + }, + "response": [] + }, + { + "name": "Get Book By ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/books/{{bookId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "books", + "{{bookId}}" + ] + }, + "description": "Get a single book by ID. Set the collection variable `bookId` first." + }, + "response": [] + }, + { + "name": "Update Book", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"status\": \"borrowed\",\n \"description\": \"Checked out by a reader.\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/books/{{bookId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "books", + "{{bookId}}" + ] + }, + "description": "Update fields of an existing book. Set the collection variable `bookId` first." + }, + "response": [] + }, + { + "name": "Delete Book", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/books/{{bookId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "books", + "{{bookId}}" + ] + }, + "description": "Delete a book by ID. Set the collection variable `bookId` first." + }, + "response": [] + } + ] +} diff --git a/src/problem5/server.ts b/src/problem5/server.ts new file mode 100644 index 0000000000..024a7da3ef --- /dev/null +++ b/src/problem5/server.ts @@ -0,0 +1,105 @@ +import express, { NextFunction, Request, Response } from "express"; +import { + createBook, + deleteBook, + getBookById, + listBooks, + parseBookFilters, + parseCreateBookInput, + parseUpdateBookInput, + updateBook, +} from "./bookService"; + +const app = express(); +const port = Number(process.env.PORT) || 3000; + +app.use(express.json()); + +function getRouteId(request: Request): string { + const id = request.params.id; + return Array.isArray(id) ? id[0] : id; +} + +app.get("/health", (_request: Request, response: Response) => { + response.json({ status: "ok" }); +}); + +app.post("/books", async (request: Request, response: Response, next: NextFunction) => { + try { + const input = parseCreateBookInput(request.body); + const book = await createBook(input); + + response.status(201).json(book); + } catch (error) { + next(error); + } +}); + +app.get("/books", async (request: Request, response: Response, next: NextFunction) => { + try { + const filters = parseBookFilters(request.query); + const result = await listBooks(filters); + + response.json({ + ...result, + limit: filters.limit, + offset: filters.offset, + }); + } catch (error) { + next(error); + } +}); + +app.get("/books/:id", async (request: Request, response: Response, next: NextFunction) => { + try { + const book = await getBookById(getRouteId(request)); + + if (book === undefined) { + response.status(404).json({ message: "Book not found." }); + return; + } + + response.json(book); + } catch (error) { + next(error); + } +}); + +app.patch("/books/:id", async (request: Request, response: Response, next: NextFunction) => { + try { + const input = parseUpdateBookInput(request.body); + const book = await updateBook(getRouteId(request), input); + + if (book === undefined) { + response.status(404).json({ message: "Book not found." }); + return; + } + + response.json(book); + } catch (error) { + next(error); + } +}); + +app.delete("/books/:id", async (request: Request, response: Response, next: NextFunction) => { + try { + const deleted = await deleteBook(getRouteId(request)); + + if (!deleted) { + response.status(404).json({ message: "Book not found." }); + return; + } + + response.status(204).send(); + } catch (error) { + next(error); + } +}); + +app.use((error: Error, _request: Request, response: Response, _next: NextFunction) => { + response.status(400).json({ message: error.message }); +}); + +app.listen(port, () => { + console.log(`Problem 5 server is running on http://localhost:${port}`); +}); diff --git a/src/problem5/types.ts b/src/problem5/types.ts new file mode 100644 index 0000000000..52c616792f --- /dev/null +++ b/src/problem5/types.ts @@ -0,0 +1,28 @@ +export type BookStatus = "available" | "borrowed" | "archived"; + +export type Book = { + id: string; + title: string; + author: string; + status: BookStatus; + description?: string; + createdAt: string; + updatedAt: string; +}; + +export type CreateBookInput = { + title: string; + author: string; + status?: BookStatus; + description?: string; +}; + +export type UpdateBookInput = Partial; + +export type BookFilters = { + status?: BookStatus; + author?: string; + q?: string; + limit?: number; + offset?: number; +}; diff --git a/src/problem6/README.md b/src/problem6/README.md new file mode 100644 index 0000000000..61f440fd56 --- /dev/null +++ b/src/problem6/README.md @@ -0,0 +1,341 @@ +# Problem 6: Live Scoreboard Module Specification + +## Purpose + +This document specifies a backend module that: + +- stores and updates user scores +- exposes the top 10 leaderboard +- pushes live leaderboard updates to connected clients +- prevents unauthorized or replayed score increases. + +The intended audience is the backend engineering team that will implement the +module inside the API service. + +## Scope + +### In scope + +- API contract for score updates and leaderboard reads +- realtime update flow +- authorization and anti-abuse controls +- persistence model + +## Requirements Mapping + +| Product requirement | Module responsibility | +| --------------------------------- | --------------------------------------------------------------------- | +| Show top 10 scores | Provide `GET /v1/scoreboard` | +| Live updates | Push leaderboard changes over SSE | +| Action completion increases score | Accept score-award requests from a trusted workflow | +| API call updates score | Provide `POST /v1/score-events` | +| Prevent cheating score increases | Require auth, validate action proof, avoid cheating api, audit events | + +## Design Summary + +The client is never allowed to submit a score directly. The client only +submits proof that a server-recognized action was completed. The API service +validates the caller, validates that the action belongs to that caller and is +eligible for scoring, writes a single immutable score event, updates the user's +total score in a transaction, recomputes the top 10 leaderboard, and publishes a +realtime update to subscribers. + +Deduplication happens at two different levels: + +- retry safety via `Idempotency-Key`, +- business-level exactly-once scoring via unique `actionCompletionId`. + +For the first implementation: + +- use HTTP + JSON for command and query APIs +- use Server-Sent Events for one-way live updates +- use a relational database as the source of truth +- use Redis Pub/Sub only when the API runs on multiple instances. + +## Architecture + +### Module boundary + +The module owns: + +- score-award request handling +- score event deduplication +- user score mutation +- leaderboard query +- realtime leaderboard publication. + +The module depends on: + +- authentication middleware +- a trusted action validation source +- the primary SQL database +- an optional cross-instance pub/sub layer. + +### Components + +#### 1. Score Event API + +Receives score-award requests from authenticated users or trusted internal +callers, validates request shape, and delegates to the application service. + +#### 2. Score Application Service + +Core orchestration layer. It: + +- verifies the action proof +- resolves the reward amount from server-side rules +- enforces idempotency +- writes the score event +- updates the aggregate user score +- fetches the latest top 10 +- emits a leaderboard-updated event. + +#### 3. Action Validation Gateway + +Reads from the trusted action system and answers: + +- did this action completion exist +- does it belong to this user +- is it eligible for score award +- has it already been consumed for scoring. + +This can be implemented by reading an internal table, calling another internal +service, or validating a server-signed completion token. + +#### 4. Leaderboard Query Service + +Reads the current top 10 ranking from persisted score totals. + +#### 5. Realtime Publisher + +Pushes `scoreboard.updated` events to connected clients through SSE. In a +multi-instance deployment, each API node subscribes to a shared pub/sub topic +and fans out the same event to its own clients. + +## Execution Flow + +Mermaid source: [scoreboard-flow-flow.png](../problem6/scoreboard-flow.png) + +## API Contract + +### 1. Submit score event + +```http +POST /v1/score-events +Authorization: Bearer +Idempotency-Key: +Content-Type: application/json +``` + +Request body: + +```json +{ + "actionCompletionId": "act_01JXYZ..." +} +``` + +Rules: + +- caller must be authenticated +- `Idempotency-Key` is required and is an opaque client-generated retry token +- `actionCompletionId` must reference a trusted completed action +- the action must belong to the authenticated user +- the action must not have been scored before +- the server resolves the score delta; the client never sends it. + +Successful response: + +```http +201 Created +``` + +```json +{ + "eventId": "evt_01JXYZ...", + "user": { + "id": "usr_123", + "score": 1250 + }, + "scoreAdded": 10, + "scoreboard": [ + { + "rank": 1, + "userId": "usr_123", + "displayName": "Alice", + "score": 1250 + } + ], + "processedAt": "2026-04-17T00:00:00.000Z" +} +``` + +Idempotency behavior: + +- `Idempotency-Key` is not a payload hash; it identifies one logical mutation + attempt so safe retries return the same result +- server should persist a canonical `request_hash` alongside the + `Idempotency-Key` to detect the same key being reused with a different payload +- same `Idempotency-Key` + same payload for the same user must return the + original success response +- same `Idempotency-Key` + different payload must return `409` +- separate idempotency keys for the same `actionCompletionId` must still be + blocked by a database uniqueness rule. + +Example: + +- first request: `Idempotency-Key = k1`, `actionCompletionId = act_123` -> + score awarded +- retry request with `k1` and the same payload -> return the original success + response, do not award score again +- request with `k1` but a different `actionCompletionId` -> return `409` +- request with a new key `k2` but the same `actionCompletionId = act_123` -> + reject because that business action has already been consumed. + +### 2. Get leaderboard + +```http +GET /v1/scoreboard?limit=10 +``` + +Successful response: + +```json +{ + "items": [ + { + "rank": 1, + "userId": "usr_123", + "displayName": "Alice", + "score": 1250 + } + ], + "updatedAt": "2026-04-17T00:00:00.000Z" +} +``` + +Rules: + +- default `limit` is `10`, +- maximum supported `limit` is `100`, +- website scoreboard should always request `10`. + +### 3. Subscribe to live leaderboard + +```http +GET /v1/scoreboard/stream +Accept: text/event-stream +Authorization: Bearer +``` + +SSE event: + +```text +event: scoreboard.updated +data: {"items":[{"rank":1,"userId":"usr_123","displayName":"Alice","score":1250}],"updatedAt":"2026-04-17T00:00:00.000Z"} +``` + +Realtime rules: + +- send current leaderboard immediately after subscribe, +- push only after a committed score update, +- clients should reconnect automatically, +- if an event is missed, client recovers by refetching `GET /v1/scoreboard`. + +## Data Model + +### `users` + +| Column | Type | Notes | +| -------------- | ------------- | ----------------------------------- | +| `id` | uuid / string | primary key | +| `display_name` | varchar | leaderboard-safe public name | +| `score` | bigint | current total score | +| `created_at` | timestamp | record creation time | +| `updated_at` | timestamp | last score change or profile change | + +Indexes: + +- primary key on `id` +- leaderboard index on `(score DESC, updated_at ASC, id ASC)` + +### `score_events` + +Immutable audit log of every accepted score increase. + +| Column | Type | Notes | +| ---------------------- | ------------- | ------------------------------------------------- | +| `id` | uuid / string | primary key | +| `user_id` | uuid / string | target user | +| `action_completion_id` | string | trusted action reference | +| `idempotency_key` | string | client-generated retry token | +| `score_delta` | integer | awarded score | +| `request_hash` | string | server-computed hash of canonical request payload | +| `created_at` | timestamp | insertion time | + +Constraints: + +- unique `(user_id, action_completion_id)` +- unique `(user_id, idempotency_key)` +- `score_delta > 0` + +Notes: + +- `idempotency_key` protects against duplicate processing caused by retries, +- `action_completion_id` protects the business rule that one completed action + can award score only once, +- `request_hash` is optional but strongly recommended to detect misuse of the + same `idempotency_key` for different payloads. + +### `action_rewards` + +| Column | Type | Notes | +| ------------- | --------- | ----------------------- | +| `action_type` | string | reward rule key | +| `score_delta` | integer | points awarded | +| `is_active` | boolean | operational kill switch | +| `updated_at` | timestamp | config change time | + +## Transaction and Consistency Rules + +The score mutation path must be transactionally safe. + +Required sequence: + +1. validate auth outside the transaction, +2. resolve action validity and reward amount, +3. begin database transaction +4. insert into `score_events` +5. update `users.score` +6. read the top 10 leaderboard +7. commit +8. publish realtime event after commit. + +Hard requirements: + +- no score change without a persisted `score_events` row +- duplicate action processing must fail even under concurrent requests +- leaderboard responses must reflect only committed score updates +- publish failure must not roll back the committed score update. + +## Security Requirements + +### Trust model + +The client is untrusted. The server must treat all score-related user input as +claims to be verified, never as facts. + +### Required controls + +- require authenticated access for score updates +- get `userId` from auth context only +- validate action completion against a trusted source +- enforce one-time consumption of each action completion +- require idempotency keys on mutating requests +- rate limit by user ID and by IP address + +## Additional Comments and Improvements + +- Should leaderboard reads be public or require authentication? +- If users have the same score in the leaderboard, how do we do? +- In the future, the leaderboard maybe more than 10. diff --git a/src/problem6/scoreboard-flow.png b/src/problem6/scoreboard-flow.png new file mode 100644 index 0000000000..a1e65c9704 Binary files /dev/null and b/src/problem6/scoreboard-flow.png differ diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..c1dfcbd082 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}