From 37cd179d77ca50f4a37e50a513debe69755d3349 Mon Sep 17 00:00:00 2001 From: tienhung278 Date: Thu, 4 Jun 2026 10:42:38 +0800 Subject: [PATCH 1/3] add solutions for sum_to_n problem --- src/problem4/index.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/problem4/index.ts diff --git a/src/problem4/index.ts b/src/problem4/index.ts new file mode 100644 index 0000000000..494feb0d53 --- /dev/null +++ b/src/problem4/index.ts @@ -0,0 +1,23 @@ +// Time Complexity: O(1) - constant time, just arithmetic operations +// Space Complexity: O(1) - no extra memory used +function sum_to_n_a(n: number): number { + return (n * (n + 1)) / 2; +} + +// Time Complexity: O(n) - iterates through all numbers from 1 to n +// Space Complexity: O(1) - only a single accumulator variable +function sum_to_n_b(n: number): number { + let sum = 0; + for (let i = 1; i <= n; i++) { + sum += i; + } + return sum; +} + +// Time Complexity: O(n) - makes n recursive calls +// Space Complexity: O(n) - each recursive call adds a frame to the call stack +function sum_to_n_c(n: number): number { + if (n <= 0) return 0; + return n + sum_to_n_c(n - 1); +} + From 18ef4a8b16095c4ac4a4ab222002d394024c0233 Mon Sep 17 00:00:00 2001 From: tienhung278 Date: Thu, 4 Jun 2026 11:52:39 +0800 Subject: [PATCH 2/3] add Todo API implementation with error handling and configuration --- src/problem5/.gitignore | 4 + src/problem5/README.md | 178 +++ src/problem5/package-lock.json | 1182 +++++++++++++++++ src/problem5/package.json | 24 + src/problem5/src/config/index.ts | 13 + .../src/controllers/todo.controller.ts | 62 + src/problem5/src/errors/index.ts | 24 + src/problem5/src/index.ts | 25 + src/problem5/src/middleware/errorHandler.ts | 18 + .../src/repositories/todo.repository.ts | 104 ++ src/problem5/src/routes/index.ts | 22 + src/problem5/src/routes/todo.routes.ts | 18 + src/problem5/src/services/todo.service.ts | 93 ++ src/problem5/src/types/todo.types.ts | 36 + src/problem5/tsconfig.json | 20 + 15 files changed, 1823 insertions(+) create mode 100644 src/problem5/.gitignore create mode 100644 src/problem5/README.md create mode 100644 src/problem5/package-lock.json create mode 100644 src/problem5/package.json create mode 100644 src/problem5/src/config/index.ts create mode 100644 src/problem5/src/controllers/todo.controller.ts create mode 100644 src/problem5/src/errors/index.ts create mode 100644 src/problem5/src/index.ts create mode 100644 src/problem5/src/middleware/errorHandler.ts create mode 100644 src/problem5/src/repositories/todo.repository.ts create mode 100644 src/problem5/src/routes/index.ts create mode 100644 src/problem5/src/routes/todo.routes.ts create mode 100644 src/problem5/src/services/todo.service.ts create mode 100644 src/problem5/src/types/todo.types.ts create mode 100644 src/problem5/tsconfig.json diff --git a/src/problem5/.gitignore b/src/problem5/.gitignore new file mode 100644 index 0000000000..2c8e1f3432 --- /dev/null +++ b/src/problem5/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +data/ + diff --git a/src/problem5/README.md b/src/problem5/README.md new file mode 100644 index 0000000000..88457a9480 --- /dev/null +++ b/src/problem5/README.md @@ -0,0 +1,178 @@ +# Problem 5 - Todo CRUD API + +A RESTful CRUD API for managing todos, built with **Express.js** and **TypeScript**, using a **JSON file-based database** for data persistence. + +## Architecture + +The project follows a **layered architecture** for maintainability and scalability: + +``` +Request → Routes → Controllers → Services → Repositories → Database +``` + +| Layer | Responsibility | +| -------------- | -------------------------------------------------------------- | +| **Routes** | HTTP verb → controller method mapping (no logic) | +| **Controllers**| Parse HTTP request, delegate to service, send HTTP response | +| **Services** | Business logic, validation rules, orchestration | +| **Repositories** | Data access abstraction (swap DB by implementing interface) | +| **Middleware** | Cross-cutting concerns (error handling, auth, logging) | + +## Prerequisites + +- [Node.js](https://nodejs.org/) (v16 or higher) +- npm (comes with Node.js) + +## Installation + +```bash +cd src/problem5 +npm install +``` + +## Running the Application + +### Development mode (with ts-node) + +```bash +npm run dev +``` + +### Production mode (compile and run) + +```bash +npm run build +npm start +``` + +The server will start on `http://localhost:3000` by default. You can change the port by setting the `PORT` environment variable: + +```bash +PORT=8080 npm run dev +``` + +## Database + +The application uses a **JSON file-based database** for simplicity and zero external dependencies. The database file is automatically created at `data/todos.json` on first run. No external database setup is required. + +To swap to a real database (e.g. PostgreSQL, MongoDB), implement the `ITodoRepository` interface in `src/repositories/todo.repository.ts` and inject it in `src/routes/index.ts`. + +## API Endpoints + +### Health Check + +``` +GET / +``` + +### Create a Todo + +``` +POST /todos +Content-Type: application/json + +{ + "title": "Buy groceries", + "description": "Milk, eggs, bread" +} +``` + +**Response:** `201 Created` + +### List Todos (with filters) + +``` +GET /todos +``` + +**Query Parameters:** + +| Parameter | Type | Description | +| ----------- | ------- | ------------------------------------------ | +| `completed` | boolean | Filter by completion status | +| `search` | string | Search in title and description | +| `limit` | number | Number of results per page (default: 20, max: 100) | +| `offset` | number | Offset for pagination (default: 0) | + +**Examples:** + +``` +GET /todos?completed=true +GET /todos?search=groceries +GET /todos?limit=10&offset=0 +``` + +**Response:** `200 OK` + +```json +{ + "data": [...], + "pagination": { + "total": 25, + "limit": 20, + "offset": 0 + } +} +``` + +### Get Todo Details + +``` +GET /todos/:id +``` + +**Response:** `200 OK` or `404 Not Found` + +### Update a Todo + +``` +PUT /todos/:id +Content-Type: application/json + +{ + "title": "Buy groceries (updated)", + "completed": true +} +``` + +All fields are optional. Only provided fields will be updated. + +**Response:** `200 OK` or `404 Not Found` + +### Delete a Todo + +``` +DELETE /todos/:id +``` + +**Response:** `200 OK` or `404 Not Found` + +## Project Structure + +``` +src/problem5/ +├── package.json +├── tsconfig.json +├── README.md +├── data/ # Auto-created at runtime +│ └── todos.json +└── src/ + ├── index.ts # App bootstrap & middleware registration + ├── config/ + │ └── index.ts # Centralized configuration + ├── errors/ + │ └── index.ts # Custom error classes (AppError, NotFoundError, etc.) + ├── middleware/ + │ └── errorHandler.ts # Global error handling middleware + ├── types/ + │ └── todo.types.ts # Shared TypeScript interfaces & DTOs + ├── repositories/ + │ └── todo.repository.ts # Data access layer (ITodoRepository interface + JSON impl) + ├── services/ + │ └── todo.service.ts # Business logic layer + ├── controllers/ + │ └── todo.controller.ts # HTTP request/response handling + └── routes/ + ├── index.ts # Central route registration & dependency wiring + └── todo.routes.ts # Route definitions (verb → controller) +``` diff --git a/src/problem5/package-lock.json b/src/problem5/package-lock.json new file mode 100644 index 0000000000..0a9b44db31 --- /dev/null +++ b/src/problem5/package-lock.json @@ -0,0 +1,1182 @@ +{ + "name": "problem5-todo-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "problem5-todo-api", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.11.5", + "@types/uuid": "^9.0.7", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } + }, + "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": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.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": "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/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true, + "license": "MIT" + }, + "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.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "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.15.1", + "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.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "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.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "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.15.1", + "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.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "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.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "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/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "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/src/problem5/package.json b/src/problem5/package.json new file mode 100644 index 0000000000..064e81adec --- /dev/null +++ b/src/problem5/package.json @@ -0,0 +1,24 @@ +{ + "name": "problem5-todo-api", + "version": "1.0.0", + "description": "A CRUD Todo API built with Express and TypeScript", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts" + }, + "dependencies": { + "express": "^4.18.2", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.11.5", + "@types/uuid": "^9.0.7", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} + + diff --git a/src/problem5/src/config/index.ts b/src/problem5/src/config/index.ts new file mode 100644 index 0000000000..6d7850f088 --- /dev/null +++ b/src/problem5/src/config/index.ts @@ -0,0 +1,13 @@ +import path from "path"; + +const config = { + port: parseInt(process.env.PORT || "3000", 10), + dataDir: path.resolve(__dirname, "../../data"), + pagination: { + defaultLimit: 20, + maxLimit: 100, + }, +}; + +export default config; + diff --git a/src/problem5/src/controllers/todo.controller.ts b/src/problem5/src/controllers/todo.controller.ts new file mode 100644 index 0000000000..189a2ecbb4 --- /dev/null +++ b/src/problem5/src/controllers/todo.controller.ts @@ -0,0 +1,62 @@ +import { Request, Response, NextFunction } from "express"; +import { TodoService } from "../services/todo.service"; +import { CreateTodoDTO, UpdateTodoDTO } from "../types/todo.types"; + +export class TodoController { + constructor(private readonly service: TodoService) {} + + create = (req: Request, res: Response, next: NextFunction): void => { + try { + const input: CreateTodoDTO = req.body; + const todo = this.service.create(input); + res.status(201).json(todo); + } catch (error) { + next(error); + } + }; + + list = (req: Request, res: Response, next: NextFunction): void => { + try { + const filters = { + completed: req.query.completed !== undefined ? req.query.completed === "true" : undefined, + search: req.query.search as string | undefined, + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : undefined, + offset: req.query.offset ? parseInt(req.query.offset as string, 10) : undefined, + }; + + const result = this.service.list(filters); + res.json(result); + } catch (error) { + next(error); + } + }; + + getById = (req: Request, res: Response, next: NextFunction): void => { + try { + const todo = this.service.getById(req.params.id); + res.json(todo); + } catch (error) { + next(error); + } + }; + + update = (req: Request, res: Response, next: NextFunction): void => { + try { + const input: UpdateTodoDTO = req.body; + const todo = this.service.update(req.params.id, input); + res.json(todo); + } catch (error) { + next(error); + } + }; + + delete = (req: Request, res: Response, next: NextFunction): void => { + try { + this.service.delete(req.params.id); + res.status(200).json({ message: "Todo deleted successfully." }); + } catch (error) { + next(error); + } + }; +} + diff --git a/src/problem5/src/errors/index.ts b/src/problem5/src/errors/index.ts new file mode 100644 index 0000000000..7472dda309 --- /dev/null +++ b/src/problem5/src/errors/index.ts @@ -0,0 +1,24 @@ +export class AppError extends Error { + public readonly statusCode: number; + public readonly isOperational: boolean; + + constructor(message: string, statusCode: number, isOperational = true) { + super(message); + this.statusCode = statusCode; + this.isOperational = isOperational; + Object.setPrototypeOf(this, AppError.prototype); + } +} + +export class NotFoundError extends AppError { + constructor(resource: string) { + super(`${resource} not found.`, 404); + } +} + +export class ValidationError extends AppError { + constructor(message: string) { + super(message, 400); + } +} + diff --git a/src/problem5/src/index.ts b/src/problem5/src/index.ts new file mode 100644 index 0000000000..c261e9320a --- /dev/null +++ b/src/problem5/src/index.ts @@ -0,0 +1,25 @@ +import express from "express"; +import config from "./config"; +import { registerRoutes } from "./routes"; +import { errorHandler } from "./middleware/errorHandler"; + +const app = express(); + +// --- Middleware --- +app.use(express.json()); + +// --- Routes --- +app.get("/", (_req, res) => { + res.json({ message: "Todo API is running", version: "1.0.0" }); +}); +app.use(registerRoutes()); + +// --- Error handling (must be last) --- +app.use(errorHandler); + +// --- Start server --- +app.listen(config.port, () => { + console.log(`Server is running on http://localhost:${config.port}`); +}); + +export default app; diff --git a/src/problem5/src/middleware/errorHandler.ts b/src/problem5/src/middleware/errorHandler.ts new file mode 100644 index 0000000000..443a355979 --- /dev/null +++ b/src/problem5/src/middleware/errorHandler.ts @@ -0,0 +1,18 @@ +import { Request, Response, NextFunction } from "express"; +import { AppError } from "../errors"; + +/** + * Global error handler middleware. + * Catches all errors thrown in controllers/services and returns a consistent JSON response. + */ +export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void { + if (err instanceof AppError) { + res.status(err.statusCode).json({ error: err.message }); + return; + } + + // Unexpected errors + console.error("Unhandled error:", err); + res.status(500).json({ error: "Internal server error." }); +} + diff --git a/src/problem5/src/repositories/todo.repository.ts b/src/problem5/src/repositories/todo.repository.ts new file mode 100644 index 0000000000..24335c045d --- /dev/null +++ b/src/problem5/src/repositories/todo.repository.ts @@ -0,0 +1,104 @@ +import fs from "fs"; +import path from "path"; +import config from "../config"; +import { Todo, TodoFilters } from "../types/todo.types"; + +/** + * Repository interface — defines the contract for data access. + * To swap databases, implement this interface with a different backend. + */ +export interface ITodoRepository { + findAll(filters: TodoFilters): { data: Todo[]; total: number }; + findById(id: string): Todo | undefined; + create(todo: Todo): Todo; + update(id: string, updates: Partial>): Todo | undefined; + delete(id: string): boolean; +} + +/** + * JSON file-based implementation of ITodoRepository. + * Can be replaced with PostgreSQL, MongoDB, etc. by implementing ITodoRepository. + */ +export class JsonTodoRepository implements ITodoRepository { + private filePath: string; + + constructor() { + // Ensure data directory exists + if (!fs.existsSync(config.dataDir)) { + fs.mkdirSync(config.dataDir, { recursive: true }); + } + + this.filePath = path.join(config.dataDir, "todos.json"); + + // Initialize DB file if it doesn't exist + if (!fs.existsSync(this.filePath)) { + fs.writeFileSync(this.filePath, JSON.stringify([], null, 2), "utf-8"); + } + } + + private readAll(): Todo[] { + const data = fs.readFileSync(this.filePath, "utf-8"); + return JSON.parse(data) as Todo[]; + } + + private writeAll(todos: Todo[]): void { + fs.writeFileSync(this.filePath, JSON.stringify(todos, null, 2), "utf-8"); + } + + findAll(filters: TodoFilters): { data: Todo[]; total: number } { + let todos = this.readAll(); + + if (filters.completed !== undefined) { + todos = todos.filter((t) => t.completed === filters.completed); + } + + if (filters.search) { + const searchLower = filters.search.toLowerCase(); + todos = todos.filter( + (t) => + t.title.toLowerCase().includes(searchLower) || + (t.description && t.description.toLowerCase().includes(searchLower)) + ); + } + + // Sort by newest first + todos.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + + const total = todos.length; + const paginated = todos.slice(filters.offset, filters.offset + filters.limit); + + return { data: paginated, total }; + } + + findById(id: string): Todo | undefined { + return this.readAll().find((t) => t.id === id); + } + + create(todo: Todo): Todo { + const todos = this.readAll(); + todos.push(todo); + this.writeAll(todos); + return todo; + } + + update(id: string, updates: Partial>): Todo | undefined { + const todos = this.readAll(); + const index = todos.findIndex((t) => t.id === id); + if (index === -1) return undefined; + + todos[index] = { ...todos[index], ...updates }; + this.writeAll(todos); + return todos[index]; + } + + delete(id: string): boolean { + const todos = this.readAll(); + const index = todos.findIndex((t) => t.id === id); + if (index === -1) return false; + + todos.splice(index, 1); + this.writeAll(todos); + return true; + } +} + diff --git a/src/problem5/src/routes/index.ts b/src/problem5/src/routes/index.ts new file mode 100644 index 0000000000..6747b9e59f --- /dev/null +++ b/src/problem5/src/routes/index.ts @@ -0,0 +1,22 @@ +import { Router } from "express"; +import { JsonTodoRepository } from "../repositories/todo.repository"; +import { TodoService } from "../services/todo.service"; +import { TodoController } from "../controllers/todo.controller"; +import { createTodoRouter } from "./todo.routes"; + +/** + * Central route registration. + * All resource routers are mounted here — makes it easy to add new resources. + */ +export function registerRoutes(): Router { + const router = Router(); + + // --- Todo resource --- + const todoRepository = new JsonTodoRepository(); + const todoService = new TodoService(todoRepository); + const todoController = new TodoController(todoService); + router.use("/todos", createTodoRouter(todoController)); + + return router; +} + diff --git a/src/problem5/src/routes/todo.routes.ts b/src/problem5/src/routes/todo.routes.ts new file mode 100644 index 0000000000..cc0f43b439 --- /dev/null +++ b/src/problem5/src/routes/todo.routes.ts @@ -0,0 +1,18 @@ +import { Router } from "express"; +import { TodoController } from "../controllers/todo.controller"; + +/** + * Registers all todo routes. + * Routes are kept thin — only mapping HTTP verbs to controller methods. + */ +export function createTodoRouter(controller: TodoController): Router { + const router = Router(); + + router.post("/", controller.create); + router.get("/", controller.list); + router.get("/:id", controller.getById); + router.put("/:id", controller.update); + router.delete("/:id", controller.delete); + + return router; +} diff --git a/src/problem5/src/services/todo.service.ts b/src/problem5/src/services/todo.service.ts new file mode 100644 index 0000000000..756e7e9d2e --- /dev/null +++ b/src/problem5/src/services/todo.service.ts @@ -0,0 +1,93 @@ +import { v4 as uuidv4 } from "uuid"; +import { ITodoRepository } from "../repositories/todo.repository"; +import { + Todo, + CreateTodoDTO, + UpdateTodoDTO, + TodoFilters, + PaginatedResult, +} from "../types/todo.types"; +import { NotFoundError, ValidationError } from "../errors"; +import config from "../config"; + +export class TodoService { + constructor(private readonly repository: ITodoRepository) {} + + create(input: CreateTodoDTO): Todo { + if (!input.title || input.title.trim().length === 0) { + throw new ValidationError("Title is required and must be a non-empty string."); + } + + const now = new Date().toISOString(); + return this.repository.create({ + id: uuidv4(), + title: input.title.trim(), + description: input.description?.trim() || null, + completed: false, + created_at: now, + updated_at: now, + }); + } + + list(filters: Partial): PaginatedResult { + const resolvedFilters: TodoFilters = { + completed: filters.completed, + search: filters.search, + limit: Math.min(filters.limit ?? config.pagination.defaultLimit, config.pagination.maxLimit), + offset: filters.offset ?? 0, + }; + + const { data, total } = this.repository.findAll(resolvedFilters); + + return { + data, + pagination: { + total, + limit: resolvedFilters.limit, + offset: resolvedFilters.offset, + }, + }; + } + + getById(id: string): Todo { + const todo = this.repository.findById(id); + if (!todo) { + throw new NotFoundError("Todo"); + } + return todo; + } + + update(id: string, input: UpdateTodoDTO): Todo { + if (input.title !== undefined && (typeof input.title !== "string" || input.title.trim().length === 0)) { + throw new ValidationError("Title must be a non-empty string."); + } + + const hasUpdates = Object.keys(input).some( + (key) => ["title", "description", "completed"].includes(key) + ); + if (!hasUpdates) { + throw new ValidationError("No valid fields to update."); + } + + const fieldsToUpdate: Partial> = {}; + + if (input.title !== undefined) fieldsToUpdate.title = input.title.trim(); + if (input.description !== undefined) + fieldsToUpdate.description = input.description?.trim() || null; + if (input.completed !== undefined) fieldsToUpdate.completed = input.completed; + fieldsToUpdate.updated_at = new Date().toISOString(); + + const updated = this.repository.update(id, fieldsToUpdate); + if (!updated) { + throw new NotFoundError("Todo"); + } + return updated; + } + + delete(id: string): void { + const deleted = this.repository.delete(id); + if (!deleted) { + throw new NotFoundError("Todo"); + } + } +} diff --git a/src/problem5/src/types/todo.types.ts b/src/problem5/src/types/todo.types.ts new file mode 100644 index 0000000000..99832ffbe9 --- /dev/null +++ b/src/problem5/src/types/todo.types.ts @@ -0,0 +1,36 @@ +export interface Todo { + id: string; + title: string; + description: string | null; + completed: boolean; + created_at: string; + updated_at: string; +} + +export interface CreateTodoDTO { + title: string; + description?: string; +} + +export interface UpdateTodoDTO { + title?: string; + description?: string; + completed?: boolean; +} + +export interface TodoFilters { + completed?: boolean; + search?: string; + limit: number; + offset: number; +} + +export interface PaginatedResult { + data: T[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +} + diff --git a/src/problem5/tsconfig.json b/src/problem5/tsconfig.json new file mode 100644 index 0000000000..3a0a331c80 --- /dev/null +++ b/src/problem5/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} + From a0c9c7854d37e155efda3f952bd369c3343af5af Mon Sep 17 00:00:00 2001 From: tienhung278 Date: Thu, 4 Jun 2026 12:05:07 +0800 Subject: [PATCH 3/3] add README for Scoreboard module detailing architecture, API design, security, and recommendations --- src/problem6/.keep | 0 src/problem6/README.md | 344 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 src/problem6/.keep create mode 100644 src/problem6/README.md 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..1c7167c2e1 --- /dev/null +++ b/src/problem6/README.md @@ -0,0 +1,344 @@ +# Scoreboard Module Specification + +## Overview + +This document specifies the backend module responsible for managing a real-time scoreboard system. The module handles score updates, maintains a live top-10 leaderboard, and prevents unauthorized score manipulation. + +--- + +## System Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ SYSTEM FLOW DIAGRAM │ +└─────────────────────────────────────────────────────────────────────────────────┘ + +┌──────────┐ ┌──────────────┐ ┌─────────────────┐ +│ Client │ │ API Gateway │ │ Auth Middleware │ +│ (Browser)│────────▶│ (HTTPS) │────────▶│ (JWT Verify) │ +└──────────┘ └──────────────┘ └────────┬────────┘ + ▲ │ + │ ▼ + │ ┌─────────────────┐ + │ │ Rate Limiter │ + │ │ (per-user) │ + │ └────────┬────────┘ + │ │ + │ ▼ + │ ┌─────────────────┐ + │ │ Score Controller │ + │ │ - validate action│ + │ │ - idempotency │ + │ └────────┬────────┘ + │ │ + │ ▼ + │ ┌─────────────────┐ + │ │ Score Service │ + │ │ - update score │ + │ │ - recalc top 10 │ + │ └────────┬────────┘ + │ │ + │ ┌───────────────────┼───────────────────┐ + │ ▼ ▼ ▼ + │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ │ Database │ │ Cache │ │ WebSocket │ + │ │ (PostgreSQL) │ │ (Redis) │ │ Server │ + │ │ - scores │ │ - top 10 cache│ │ │ + │ │ - actions │ │ - sorted set │ │ │ + │ └──────────────┘ └──────────────┘ └──────┬───────┘ + │ │ + │◀───────────────────────────────────────────────────────────────────┘ + │ (real-time broadcast: top 10 update) + │ +``` + +--- + +## Execution Flow + +### 1. Score Update Flow + +``` +User completes action + │ + ▼ +POST /api/scores/actions +(Authorization: Bearer ) +(Body: { action_id, action_type, payload }) + │ + ▼ +┌───────────────────┐ +│ Auth Middleware │──── Invalid token ──▶ 401 Unauthorized +│ - Verify JWT │ +│ - Extract user_id │ +└───────┬───────────┘ + │ valid + ▼ +┌───────────────────┐ +│ Rate Limiter │──── Exceeded ──▶ 429 Too Many Requests +│ - Max N actions │ +│ per minute │ +└───────┬───────────┘ + │ allowed + ▼ +┌───────────────────┐ +│ Score Controller │──── Duplicate action_id ──▶ 409 Conflict +│ - Validate input │ +│ - Check idempotency│ +│ (action_id) │ +└───────┬───────────┘ + │ valid & new + ▼ +┌───────────────────┐ +│ Score Service │ +│ - Record action │ +│ - Increment score │ +│ - Update Redis │ +│ sorted set │ +└───────┬───────────┘ + │ + ▼ +┌───────────────────┐ +│ Check: Did top 10 │──── No change ──▶ 200 OK (return new score) +│ change? │ +└───────┬───────────┘ + │ yes + ▼ +┌───────────────────┐ +│ WebSocket Broadcast│ +│ - Emit "scoreboard│ +│ _update" event │ +│ - Payload: top 10 │ +└───────────────────┘ + │ + ▼ + 200 OK (return new score) +``` + +### 2. Scoreboard Read Flow + +``` +GET /api/scores/top + │ + ▼ +┌───────────────────┐ +│ Cache Lookup │──── Cache hit ──▶ 200 OK (return cached top 10) +│ (Redis sorted set)│ +└───────┬───────────┘ + │ cache miss + ▼ +┌───────────────────┐ +│ Database Query │ +│ - SELECT top 10 │ +│ - Populate cache │ +└───────────────────┘ + │ + ▼ + 200 OK (return top 10) +``` + +### 3. WebSocket Connection Flow + +``` +Client connects to ws://host/scoreboard + │ + ▼ +┌───────────────────┐ +│ Authenticate │──── Invalid ──▶ Close connection +│ (token in query │ +│ or first message)│ +└───────┬───────────┘ + │ valid + ▼ +┌───────────────────┐ +│ Subscribe to │ +│ "scoreboard_update"│ +│ channel │ +└───────────────────┘ + │ + ▼ + Send current top 10 snapshot + Listen for future broadcasts +``` + +--- + +## API Endpoints + +### `POST /api/scores/actions` + +Submit a completed action to update the user's score. + +**Headers:** +``` +Authorization: Bearer +Content-Type: application/json +``` + +**Request Body:** +```json +{ + "action_id": "550e8400-e29b-41d4-a716-446655440000", + "action_type": "task_complete", + "metadata": {} +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `action_id` | UUID | Yes | Client-generated unique ID for idempotency | +| `action_type` | string | Yes | Type of action performed | +| `metadata` | object | No | Action-specific data for audit purposes | + +**Responses:** + +| Status | Description | +|--------|-------------| +| 200 | Score updated successfully | +| 401 | Invalid or missing authentication token | +| 409 | Duplicate action_id (already processed) | +| 422 | Invalid request body | +| 429 | Rate limit exceeded | +| 500 | Internal server error | + +**Success Response:** +```json +{ + "user_id": "user-123", + "new_score": 150, + "rank": 7 +} +``` + +--- + +### `GET /api/scores/top` + +Retrieve the current top 10 scoreboard. + +**Responses:** + +| Status | Description | +|--------|-------------| +| 200 | Scoreboard returned | +| 500 | Internal server error | + +**Success Response:** +```json +{ + "data": [ + { "rank": 1, "user_id": "user-456", "username": "alice", "score": 980 }, + { "rank": 2, "user_id": "user-789", "username": "bob", "score": 870 } + ], + "updated_at": "2026-06-04T12:00:00Z" +} +``` + +--- + +### `WebSocket /ws/scoreboard` + +Real-time scoreboard updates pushed to connected clients. + +**Event: `scoreboard_update`** +```json +{ + "event": "scoreboard_update", + "data": [ + { "rank": 1, "user_id": "user-456", "username": "alice", "score": 985 }, + { "rank": 2, "user_id": "user-789", "username": "bob", "score": 870 } + ], + "updated_at": "2026-06-04T12:00:05Z" +} +``` + +--- + +## Database Schema + +### `users` table +| Column | Type | Description | +|--------|------|-------------| +| id | UUID (PK) | User identifier | +| username | VARCHAR(50) | Display name | +| score | INTEGER | Current total score | +| updated_at | TIMESTAMP | Last score update time | + +### `score_actions` table (audit log) +| Column | Type | Description | +|--------|------|-------------| +| id | UUID (PK) | Internal record ID | +| action_id | UUID (UNIQUE) | Client-provided idempotency key | +| user_id | UUID (FK) | User who performed the action | +| action_type | VARCHAR(50) | Type of action | +| points | INTEGER | Points awarded | +| created_at | TIMESTAMP | When the action was recorded | + +**Indexes:** +- `score_actions.action_id` — UNIQUE index (idempotency lookups) +- `users.score` — DESC index (fast top-10 queries) + +--- + +## Security Measures + +| Threat | Mitigation | +|--------|-----------| +| Unauthorized score updates | JWT authentication on all write endpoints | +| Token replay / stolen tokens | Short-lived tokens (15 min) + refresh token rotation | +| Rapid-fire fake actions | Per-user rate limiting (e.g., max 10 actions/minute) | +| Duplicate action submission | Idempotency key (`action_id`) — reject duplicates with 409 | +| Score manipulation via API replay | Signed action payloads (HMAC) from the client application | +| Direct DB manipulation | Application-layer validation; no direct DB access exposed | + +--- + +## Technology Recommendations + +| Component | Technology | Rationale | +|-----------|-----|-----------| +| Server | Node.js + Express/Fastify | Efficient for I/O-bound real-time workloads | +| Database | PostgreSQL | Reliable, supports indexes for leaderboard queries | +| Cache | Redis (Sorted Sets) | O(log N) score updates, O(1) top-K retrieval | +| Real-time | WebSocket (Socket.IO or ws) | Bi-directional, low-latency push | +| Auth | JWT | Stateless, verifiable tokens | +| Rate Limiting | Redis-based sliding window | Distributed, consistent across instances | + +--- + +## Additional Comments & Improvements + +### 1. Horizontal Scalability +- Use Redis Pub/Sub to fan out WebSocket broadcasts across multiple server instances. +- Stateless API servers behind a load balancer; session state lives in Redis. + +### 2. Action Verification (Server-Side Validation) +- If actions can be verified (e.g., a quiz answer), validate the action server-side before awarding points. Never trust the client to determine the score increment. +- Consider a **signed action token** pattern: the server issues a challenge token when the action starts, and the client must return it upon completion. This prevents users from calling the score endpoint without actually performing the action. + +### 3. Batching & Debouncing +- If the top-10 changes frequently under high load, debounce WebSocket broadcasts (e.g., emit at most once per 500ms) to reduce client re-renders and network overhead. + +### 4. Audit Trail & Anomaly Detection +- Log all score actions for audit. Run periodic anomaly detection (e.g., flag users with score velocity > 3σ from mean). +- Implement a moderation dashboard for manual review of flagged accounts. + +### 5. Graceful Degradation +- If Redis is down, fall back to direct DB queries for the top-10. +- If WebSocket delivery fails, clients should poll `/api/scores/top` as a fallback (e.g., every 30s). + +### 6. Pagination & Extended Leaderboard +- While the requirement is top 10, design the sorted set and API to support arbitrary ranges (`?offset=0&limit=10`) for future expansion (e.g., "show my position and nearby players"). + +### 7. Score Atomicity +- Use Redis `ZINCRBY` for atomic score increments in the sorted set. +- Wrap DB score update + action insert in a transaction to prevent partial writes. + +### 8. Testing Strategy +- **Unit tests**: Service layer logic (score calculation, validation). +- **Integration tests**: API endpoints with test DB. +- **Load tests**: Simulate concurrent score submissions to validate rate limiting and sorted set performance. + + + +