diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..ef5a800e1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +coverage/ +package-lock.json diff --git a/src/problem4/README.md b/src/problem4/README.md new file mode 100644 index 0000000000..b1ad7cc236 --- /dev/null +++ b/src/problem4/README.md @@ -0,0 +1,19 @@ +# Problem 4: Three Ways to Sum to n + +This folder provides three unique TypeScript implementations of `sum_to_n`. + +## Implementations + +- `sum_to_n_a`: iterative loop, simple and explicit, `O(|n|)` time and `O(1)` space. +- `sum_to_n_b`: arithmetic-series formula, fastest option at `O(1)` time and `O(1)` space. +- `sum_to_n_c`: recursive implementation, valid but less efficient because it uses `O(|n|)` stack space. + +For negative numbers, the functions sum from `0` down to `n` so the behavior stays deterministic for any integer input. + +## Test + +```bash +cd src/problem4 +npm install +npm test +``` diff --git a/src/problem4/package.json b/src/problem4/package.json new file mode 100644 index 0000000000..d74cb278cb --- /dev/null +++ b/src/problem4/package.json @@ -0,0 +1,15 @@ +{ + "name": "problem4", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --import tsx --test ./test/sum-to-n.test.ts", + "build": "tsc -p tsconfig.json" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + } +} diff --git a/src/problem4/src/index.ts b/src/problem4/src/index.ts new file mode 100644 index 0000000000..6d6d4ab60a --- /dev/null +++ b/src/problem4/src/index.ts @@ -0,0 +1,43 @@ +export function sum_to_n_a(n: number): number { + // Iterative approach: O(|n|) time, O(1) space. + let total = 0; + + if (n >= 0) { + for (let value = 1; value <= n; value += 1) { + total += value; + } + + return total; + } + + for (let value = 0; value >= n; value -= 1) { + total += value; + } + + return total; +} + +export function sum_to_n_b(n: number): number { + // Closed-form arithmetic series: O(1) time, O(1) space. + if (n >= 0) { + return (n * (n + 1)) / 2; + } + + return (Math.abs(n) * (n - 1)) / 2; +} + +export function sum_to_n_c(n: number): number { + // Recursive walk: O(|n|) time, O(|n|) call stack space. + const step = n >= 0 ? 1 : -1; + const stop = n >= 0 ? n + 1 : n - 1; + + const walk = (current: number, total: number): number => { + if (current === stop) { + return total; + } + + return walk(current + step, total + current); + }; + + return walk(step > 0 ? 1 : 0, 0); +} diff --git a/src/problem4/test/sum-to-n.test.ts b/src/problem4/test/sum-to-n.test.ts new file mode 100644 index 0000000000..b0eeec7955 --- /dev/null +++ b/src/problem4/test/sum-to-n.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { sum_to_n_a, sum_to_n_b, sum_to_n_c } from "../src/index.js"; + +const cases = [ + { input: 0, expected: 0 }, + { input: 1, expected: 1 }, + { input: 5, expected: 15 }, + { input: -3, expected: -6 }, + { input: 10, expected: 55 } +] as const; + +for (const fn of [sum_to_n_a, sum_to_n_b, sum_to_n_c]) { + test(`${fn.name} returns the expected summation`, () => { + for (const { input, expected } of cases) { + assert.equal(fn(input), expected); + } + }); +} diff --git a/src/problem4/tsconfig.json b/src/problem4/tsconfig.json new file mode 100644 index 0000000000..17291bc86f --- /dev/null +++ b/src/problem4/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": true, + "outDir": "dist", + "rootDir": ".", + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/src/problem5/.env.example b/src/problem5/.env.example new file mode 100644 index 0000000000..b64d96b9b5 --- /dev/null +++ b/src/problem5/.env.example @@ -0,0 +1,3 @@ +PORT=3000 +MONGODB_URI=mongodb://127.0.0.1:27017/code_challenge_problem5 +AUTH_TOKEN=replace-with-a-strong-token diff --git a/src/problem5/README.md b/src/problem5/README.md new file mode 100644 index 0000000000..a4f08e0518 --- /dev/null +++ b/src/problem5/README.md @@ -0,0 +1,223 @@ +# Problem 5: A Crude Server + +A RESTful CRUD API server built with Express.js and TypeScript, using MongoDB for persistence and Bearer token authentication for protected endpoints. + +## Features + +- CRUD operations for resource creation, listing, detail lookup, update, and deletion. +- Basic filtering with `name` and `status` query parameters. +- MongoDB persistence through Mongoose. +- Bearer token authentication controlled by environment configuration. +- Interactive Swagger/OpenAPI documentation for browser-based testing. +- TypeScript-based codebase with integration tests for the API flow. + +## Tech Stack + +- **Runtime**: Node.js +- **Framework**: Express.js +- **Language**: TypeScript +- **Database**: MongoDB with Mongoose +- **Documentation**: Swagger/OpenAPI 3.0 with Swagger UI +- **Testing**: Node test runner, Supertest, mongodb-memory-server + +## Resource Model + +```json +{ + "id": "ObjectId", + "name": "Test Resource", + "value": "xxxxx", + "description": "A test resource", + "status": 1, + "createdAt": "2026-01-25T10:00:00.000Z", + "updatedAt": "2026-01-25T10:00:00.000Z", + "detail": {} +} +``` + +## Endpoints + +- `POST /resources` creates a resource. +- `GET /resources?name=abc&status=1` lists resources with basic filters. +- `GET /resources/:id` returns a resource detail. +- `PUT /resources/:id` updates a resource. +- `DELETE /resources/:id` deletes a resource. + +All endpoints require `Authorization: Bearer `. + +## Installation + +1. Move into the problem folder: + +```bash +cd src/problem5 +``` + +2. Install dependencies: + +```bash +npm install +``` + +3. Copy the environment file: + +```bash +cp .env.example .env +``` + +4. Update the values in `.env`: + +```env +PORT=3000 +MONGODB_URI=mongodb://127.0.0.1:27017/code_challenge_problem5 +AUTH_TOKEN=replace-with-a-strong-token +``` + +## Running the Application + +### Development Mode + +```bash +npm run dev +``` + +### Production Build + +```bash +npm run build +``` + +If you want to start the compiled output manually: + +```bash +node dist/src/server.js +``` + +The server starts on `http://localhost:3000` by default. + +## API Documentation + +### Swagger UI + +Once the server is running, open: + +- [http://localhost:3000/api-docs](http://localhost:3000/api-docs) + +Swagger is public for convenience, but the actual CRUD endpoints still require Bearer authentication. + +How to test in Swagger UI: + +1. Open the Swagger page. +2. Expand any `/resources` endpoint. +3. Click `Authorize`. +4. Enter the token as plain text from `AUTH_TOKEN`. +5. Run requests directly from the browser. + +Useful supporting endpoint: + +- [http://localhost:3000/openapi.json](http://localhost:3000/openapi.json) exposes the raw OpenAPI document. + +## API Endpoints + +- `POST /resources` creates a resource. +- `GET /resources?name=abc&status=1` lists resources with basic filters. +- `GET /resources/:id` returns a resource detail. +- `PUT /resources/:id` updates a resource. +- `DELETE /resources/:id` deletes a resource. + +## Testing with cURL + +All examples below assume: + +```bash +set TOKEN=replace-with-a-strong-token +``` + +If you are using PowerShell, use: + +```powershell +$TOKEN = "replace-with-a-strong-token" +``` + +### 1. Create a resource + +```bash +curl -X POST http://localhost:3000/resources \ + -H "Authorization: Bearer %TOKEN%" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"Test Resource\",\"value\":\"xxxxx\",\"description\":\"A test resource\",\"status\":1,\"detail\":{\"source\":\"curl\"}}" +``` + +PowerShell example: + +```powershell +curl.exe -X POST http://localhost:3000/resources ` + -H "Authorization: Bearer $TOKEN" ` + -H "Content-Type: application/json" ` + -d "{\"name\":\"Test Resource\",\"value\":\"xxxxx\",\"description\":\"A test resource\",\"status\":1,\"detail\":{\"source\":\"powershell\"}}" +``` + +### 2. List resources + +```bash +curl "http://localhost:3000/resources?name=Test&status=1" \ + -H "Authorization: Bearer %TOKEN%" +``` + +### 3. Get resource detail + +```bash +curl http://localhost:3000/resources/ \ + -H "Authorization: Bearer %TOKEN%" +``` + +### 4. Update a resource + +```bash +curl -X PUT http://localhost:3000/resources/ \ + -H "Authorization: Bearer %TOKEN%" \ + -H "Content-Type: application/json" \ + -d "{\"description\":\"Updated description\",\"status\":0}" +``` + +### 5. Delete a resource + +```bash +curl -X DELETE http://localhost:3000/resources/ \ + -H "Authorization: Bearer %TOKEN%" +``` + +## Test + +```bash +npm test +``` + +## Project Structure + +```text +src/problem5/ +├── src/ +│ ├── config/ +│ │ ├── database.ts +│ │ ├── env.ts +│ │ └── swagger.ts +│ ├── controllers/ +│ │ └── resource-controller.ts +│ ├── middleware/ +│ │ └── auth.ts +│ ├── models/ +│ │ └── resource.ts +│ ├── routes/ +│ │ └── resource-routes.ts +│ ├── types/ +│ │ └── express.d.ts +│ ├── app.ts +│ └── server.ts +├── test/ +│ └── resource-api.test.ts +├── .env.example +├── package.json +├── tsconfig.json +└── README.md +``` diff --git a/src/problem5/package.json b/src/problem5/package.json new file mode 100644 index 0000000000..013a6212c7 --- /dev/null +++ b/src/problem5/package.json @@ -0,0 +1,29 @@ +{ + "name": "problem5", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "test": "node --import tsx --test ./test/resource-api.test.ts", + "build": "tsc -p tsconfig.json" + }, + "dependencies": { + "dotenv": "^17.2.3", + "express": "^5.1.0", + "mongoose": "^8.19.1", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1" + }, + "devDependencies": { + "@types/express": "^5.0.3", + "@types/node": "^24.6.0", + "@types/supertest": "^6.0.3", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.8", + "mongodb-memory-server": "^10.2.3", + "supertest": "^7.1.4", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + } +} diff --git a/src/problem5/src/app.ts b/src/problem5/src/app.ts new file mode 100644 index 0000000000..d30eec0d68 --- /dev/null +++ b/src/problem5/src/app.ts @@ -0,0 +1,52 @@ +import express from "express"; +import swaggerUi from "swagger-ui-express"; + +import { swaggerSpec } from "./config/swagger.js"; +import { resourceRouter } from "./routes/resource-routes.js"; + +export function createApp() { + const app = express(); + app.use(express.json()); + app.get("/", (_request, response) => { + response.json({ + message: "Welcome to Problem 5 CRUD API", + documentation: "/api-docs", + openApiSpec: "/openapi.json" + }); + }); + app.get("/openapi.json", (_request, response) => { + response.json(swaggerSpec); + }); + app.use( + "/api-docs", + swaggerUi.serve, + swaggerUi.setup(swaggerSpec, { + customSiteTitle: "Problem 5 API Docs", + customCss: ".swagger-ui .topbar { display: none }" + }) + ); + app.use(resourceRouter); + + app.use((error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => { + if (error instanceof Error) { + response.status(400).json({ + success: false, + error: { + code: "BAD_REQUEST", + message: error.message + } + }); + return; + } + + response.status(500).json({ + success: false, + error: { + code: "INTERNAL_SERVER_ERROR", + message: "An unexpected error occurred." + } + }); + }); + + return app; +} diff --git a/src/problem5/src/config/database.ts b/src/problem5/src/config/database.ts new file mode 100644 index 0000000000..83b04475d7 --- /dev/null +++ b/src/problem5/src/config/database.ts @@ -0,0 +1,11 @@ +import mongoose from "mongoose"; + +import { env } from "./env.js"; + +export async function connectDatabase(uri = env.mongoUri) { + await mongoose.connect(uri); +} + +export async function disconnectDatabase() { + await mongoose.disconnect(); +} diff --git a/src/problem5/src/config/env.ts b/src/problem5/src/config/env.ts new file mode 100644 index 0000000000..324741e6b5 --- /dev/null +++ b/src/problem5/src/config/env.ts @@ -0,0 +1,11 @@ +import dotenv from "dotenv"; + +dotenv.config(); + +const defaultPort = 3000; + +export const env = { + authToken: process.env.AUTH_TOKEN ?? "change-me", + mongoUri: process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017/code_challenge_problem5", + port: Number(process.env.PORT ?? defaultPort) +}; diff --git a/src/problem5/src/config/swagger.ts b/src/problem5/src/config/swagger.ts new file mode 100644 index 0000000000..655da8a4c3 --- /dev/null +++ b/src/problem5/src/config/swagger.ts @@ -0,0 +1,291 @@ +import swaggerJsdoc from "swagger-jsdoc"; + +const swaggerDefinition: swaggerJsdoc.Options["definition"] = { + openapi: "3.0.3", + info: { + title: "Problem 5 CRUD API", + version: "1.0.0", + description: "A RESTful CRUD API built with Express.js, TypeScript, MongoDB, and Bearer token authentication." + }, + servers: [ + { + url: "http://localhost:3000", + description: "Local development server" + } + ], + tags: [ + { + name: "Resources", + description: "CRUD endpoints for resource management" + } + ], + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "Token" + } + }, + schemas: { + Resource: { + type: "object", + properties: { + id: { + type: "string", + example: "680f4478d2ef40f533f51111" + }, + name: { + type: "string", + example: "Test Resource" + }, + value: { + type: "string", + example: "xxxxx" + }, + description: { + type: "string", + example: "A test resource" + }, + status: { + type: "integer", + enum: [0, 1], + example: 1 + }, + createdAt: { + type: "string", + format: "date-time" + }, + updatedAt: { + type: "string", + format: "date-time" + }, + detail: { + type: "object", + additionalProperties: true, + example: {} + } + }, + required: ["id", "name", "value", "description", "status", "createdAt", "updatedAt", "detail"] + }, + CreateResourceRequest: { + type: "object", + required: ["name", "value"], + properties: { + name: { + type: "string", + example: "Test Resource" + }, + value: { + type: "string", + example: "xxxxx" + }, + description: { + type: "string", + example: "A test resource" + }, + status: { + type: "integer", + enum: [0, 1], + example: 1 + }, + detail: { + type: "object", + additionalProperties: true, + example: { + scope: "integration-test" + } + } + } + }, + UpdateResourceRequest: { + type: "object", + properties: { + name: { + type: "string" + }, + value: { + type: "string" + }, + description: { + type: "string" + }, + status: { + type: "integer", + enum: [0, 1] + }, + detail: { + type: "object", + additionalProperties: true + } + } + }, + ErrorResponse: { + type: "object", + properties: { + success: { + type: "boolean", + example: false + }, + error: { + type: "object", + properties: { + code: { + type: "string", + example: "UNAUTHORIZED" + }, + message: { + type: "string", + example: "Missing Bearer token." + } + } + } + } + } + } + } +}; + +export const swaggerSpec = swaggerJsdoc({ + definition: swaggerDefinition, + apis: [] +}) as swaggerJsdoc.OAS3Definition & { + paths: Record; +}; + +swaggerSpec.paths = { + "/resources": { + get: { + tags: ["Resources"], + summary: "List resources", + security: [{ bearerAuth: [] }], + parameters: [ + { + in: "query", + name: "name", + schema: { type: "string" }, + description: "Case-insensitive partial match on resource name." + }, + { + in: "query", + name: "status", + schema: { type: "integer", enum: [0, 1] }, + description: "Filter by status." + } + ], + responses: { + "200": { + description: "Resources returned successfully." + }, + "401": { + description: "Unauthorized.", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + post: { + tags: ["Resources"], + summary: "Create a resource", + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/CreateResourceRequest" + } + } + } + }, + responses: { + "201": { + description: "Resource created successfully." + }, + "401": { + description: "Unauthorized." + } + } + } + }, + "/resources/{id}": { + get: { + tags: ["Resources"], + summary: "Get resource details", + security: [{ bearerAuth: [] }], + parameters: [ + { + in: "path", + name: "id", + required: true, + schema: { type: "string" } + } + ], + responses: { + "200": { + description: "Resource returned successfully." + }, + "404": { + description: "Resource not found." + } + } + }, + put: { + tags: ["Resources"], + summary: "Update a resource", + security: [{ bearerAuth: [] }], + parameters: [ + { + in: "path", + name: "id", + required: true, + schema: { type: "string" } + } + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/UpdateResourceRequest" + } + } + } + }, + responses: { + "200": { + description: "Resource updated successfully." + }, + "404": { + description: "Resource not found." + } + } + }, + delete: { + tags: ["Resources"], + summary: "Delete a resource", + security: [{ bearerAuth: [] }], + parameters: [ + { + in: "path", + name: "id", + required: true, + schema: { type: "string" } + } + ], + responses: { + "204": { + description: "Resource deleted successfully." + }, + "404": { + description: "Resource not found." + } + } + } + } +}; diff --git a/src/problem5/src/controllers/resource-controller.ts b/src/problem5/src/controllers/resource-controller.ts new file mode 100644 index 0000000000..0d46b9b951 --- /dev/null +++ b/src/problem5/src/controllers/resource-controller.ts @@ -0,0 +1,157 @@ +import type { Request, Response } from "express"; +import mongoose from "mongoose"; + +import { ResourceModel } from "../models/resource.js"; + +function buildPayload(document: unknown) { + return document; +} + +export async function createResource(request: Request, response: Response) { + const resource = await ResourceModel.create({ + name: request.body.name, + value: request.body.value, + description: request.body.description ?? "", + status: request.body.status ?? 1, + detail: request.body.detail ?? {} + }); + + response.status(201).json({ + success: true, + data: buildPayload(resource.toJSON()) + }); +} + +export async function listResources(request: Request, response: Response) { + const filters: Record = {}; + + if (typeof request.query.name === "string" && request.query.name.trim() !== "") { + filters.name = { + $regex: request.query.name.trim(), + $options: "i" + }; + } + + if (typeof request.query.status === "string" && request.query.status !== "") { + filters.status = Number(request.query.status); + } + + const items = await ResourceModel.find(filters).sort({ updatedAt: -1, createdAt: -1 }).lean(); + + response.json({ + success: true, + data: { + items: items.map((item) => ({ + ...item, + id: item._id.toString(), + _id: undefined + })), + total: items.length + } + }); +} + +export async function getResource(request: Request, response: Response) { + if (!mongoose.isValidObjectId(request.params.id)) { + response.status(404).json({ + success: false, + error: { + code: "RESOURCE_NOT_FOUND", + message: "Resource was not found." + } + }); + return; + } + + const resource = await ResourceModel.findById(request.params.id); + + if (!resource) { + response.status(404).json({ + success: false, + error: { + code: "RESOURCE_NOT_FOUND", + message: "Resource was not found." + } + }); + return; + } + + response.json({ + success: true, + data: buildPayload(resource.toJSON()) + }); +} + +export async function updateResource(request: Request, response: Response) { + if (!mongoose.isValidObjectId(request.params.id)) { + response.status(404).json({ + success: false, + error: { + code: "RESOURCE_NOT_FOUND", + message: "Resource was not found." + } + }); + return; + } + + const resource = await ResourceModel.findByIdAndUpdate( + request.params.id, + { + $set: { + ...(request.body.name !== undefined ? { name: request.body.name } : {}), + ...(request.body.value !== undefined ? { value: request.body.value } : {}), + ...(request.body.description !== undefined ? { description: request.body.description } : {}), + ...(request.body.status !== undefined ? { status: request.body.status } : {}), + ...(request.body.detail !== undefined ? { detail: request.body.detail } : {}) + } + }, + { + new: true, + runValidators: true + } + ); + + if (!resource) { + response.status(404).json({ + success: false, + error: { + code: "RESOURCE_NOT_FOUND", + message: "Resource was not found." + } + }); + return; + } + + response.json({ + success: true, + data: buildPayload(resource.toJSON()) + }); +} + +export async function deleteResource(request: Request, response: Response) { + if (!mongoose.isValidObjectId(request.params.id)) { + response.status(404).json({ + success: false, + error: { + code: "RESOURCE_NOT_FOUND", + message: "Resource was not found." + } + }); + return; + } + + const resource = await ResourceModel.findByIdAndDelete(request.params.id); + + if (!resource) { + response.status(404).json({ + success: false, + error: { + code: "RESOURCE_NOT_FOUND", + message: "Resource was not found." + } + }); + return; + } + + response.status(204).send(); +} diff --git a/src/problem5/src/middleware/auth.ts b/src/problem5/src/middleware/auth.ts new file mode 100644 index 0000000000..f27297888b --- /dev/null +++ b/src/problem5/src/middleware/auth.ts @@ -0,0 +1,35 @@ +import type { NextFunction, Request, Response } from "express"; + +import { env } from "../config/env.js"; + +export function requireAuth(request: Request, response: Response, next: NextFunction) { + const authorization = request.header("Authorization"); + const expectedToken = process.env.AUTH_TOKEN ?? env.authToken; + + if (!authorization?.startsWith("Bearer ")) { + response.status(401).json({ + success: false, + error: { + code: "UNAUTHORIZED", + message: "Missing Bearer token." + } + }); + return; + } + + const token = authorization.slice("Bearer ".length); + + if (token !== expectedToken) { + response.status(401).json({ + success: false, + error: { + code: "INVALID_TOKEN", + message: "Auth token is invalid." + } + }); + return; + } + + request.authToken = token; + next(); +} diff --git a/src/problem5/src/models/resource.ts b/src/problem5/src/models/resource.ts new file mode 100644 index 0000000000..a0039f4bc2 --- /dev/null +++ b/src/problem5/src/models/resource.ts @@ -0,0 +1,45 @@ +import { Schema, model, type InferSchemaType } from "mongoose"; + +const resourceSchema = new Schema( + { + name: { + type: String, + required: true, + trim: true + }, + value: { + type: String, + required: true, + trim: true + }, + description: { + type: String, + default: "", + trim: true + }, + status: { + type: Number, + enum: [0, 1], + default: 1 + }, + detail: { + type: Schema.Types.Mixed, + default: {} + } + }, + { + timestamps: true, + versionKey: false, + toJSON: { + transform: (_doc, ret: Record) => { + ret.id = String(ret._id); + ret._id = undefined; + return ret; + } + } + } +); + +export type ResourceDocument = InferSchemaType; + +export const ResourceModel = model("Resource", resourceSchema); diff --git a/src/problem5/src/routes/resource-routes.ts b/src/problem5/src/routes/resource-routes.ts new file mode 100644 index 0000000000..9ac6d09316 --- /dev/null +++ b/src/problem5/src/routes/resource-routes.ts @@ -0,0 +1,19 @@ +import { Router } from "express"; + +import { + createResource, + deleteResource, + getResource, + listResources, + updateResource +} from "../controllers/resource-controller.js"; +import { requireAuth } from "../middleware/auth.js"; + +export const resourceRouter = Router(); + +resourceRouter.use(requireAuth); +resourceRouter.post("/resources", createResource); +resourceRouter.get("/resources", listResources); +resourceRouter.get("/resources/:id", getResource); +resourceRouter.put("/resources/:id", updateResource); +resourceRouter.delete("/resources/:id", deleteResource); diff --git a/src/problem5/src/server.ts b/src/problem5/src/server.ts new file mode 100644 index 0000000000..c21c28aaeb --- /dev/null +++ b/src/problem5/src/server.ts @@ -0,0 +1,17 @@ +import { connectDatabase } from "./config/database.js"; +import { env } from "./config/env.js"; +import { createApp } from "./app.js"; + +async function bootstrap() { + await connectDatabase(); + + const app = createApp(); + app.listen(env.port, () => { + console.log(`Problem 5 API listening on port ${env.port}`); + }); +} + +bootstrap().catch((error) => { + console.error("Failed to start server", error); + process.exitCode = 1; +}); diff --git a/src/problem5/src/types/express.d.ts b/src/problem5/src/types/express.d.ts new file mode 100644 index 0000000000..ec787da7e0 --- /dev/null +++ b/src/problem5/src/types/express.d.ts @@ -0,0 +1,9 @@ +declare global { + namespace Express { + interface Request { + authToken?: string; + } + } +} + +export {}; diff --git a/src/problem5/test/resource-api.test.ts b/src/problem5/test/resource-api.test.ts new file mode 100644 index 0000000000..f30d368ec2 --- /dev/null +++ b/src/problem5/test/resource-api.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import request from "supertest"; + +import { createApp } from "../src/app.js"; + +const authToken = "test-token"; + +test("resource API enforces auth and supports CRUD flows", async () => { + const mongoServer = await MongoMemoryServer.create(); + + try { + process.env.MONGODB_URI = mongoServer.getUri(); + process.env.AUTH_TOKEN = authToken; + + await mongoose.connect(process.env.MONGODB_URI); + + const app = createApp(); + const agent = request(app); + + await agent.get("/resources").expect(401); + + const createResponse = await agent + .post("/resources") + .set("Authorization", `Bearer ${authToken}`) + .send({ + name: "Test Resource", + value: "xxxxx", + description: "A test resource", + status: 1, + detail: { + scope: "integration-test" + } + }) + .expect(201); + + assert.equal(createResponse.body.success, true); + assert.equal(createResponse.body.data.name, "Test Resource"); + assert.ok(createResponse.body.data.id); + + const resourceId = createResponse.body.data.id as string; + + const listResponse = await agent + .get("/resources?status=1&name=Test") + .set("Authorization", `Bearer ${authToken}`) + .expect(200); + + assert.equal(listResponse.body.data.items.length, 1); + assert.equal(listResponse.body.data.total, 1); + + const detailResponse = await agent + .get(`/resources/${resourceId}`) + .set("Authorization", `Bearer ${authToken}`) + .expect(200); + + assert.equal(detailResponse.body.data.id, resourceId); + + const updateResponse = await agent + .put(`/resources/${resourceId}`) + .set("Authorization", `Bearer ${authToken}`) + .send({ + description: "Updated description", + status: 0 + }) + .expect(200); + + assert.equal(updateResponse.body.data.description, "Updated description"); + assert.equal(updateResponse.body.data.status, 0); + + await agent + .delete(`/resources/${resourceId}`) + .set("Authorization", `Bearer ${authToken}`) + .expect(204); + + await agent + .get(`/resources/${resourceId}`) + .set("Authorization", `Bearer ${authToken}`) + .expect(404); + } finally { + await mongoose.disconnect(); + await mongoServer.stop(); + } +}); + +test("swagger documentation is publicly accessible", async () => { + const app = createApp(); + const agent = request(app); + + const docsResponse = await agent.get("/api-docs/").expect(200); + assert.match(docsResponse.text, /swagger-ui/i); + + const specResponse = await agent.get("/openapi.json").expect(200); + assert.equal(specResponse.body.openapi, "3.0.3"); + assert.ok(specResponse.body.paths["/resources"]); +}); diff --git a/src/problem5/tsconfig.json b/src/problem5/tsconfig.json new file mode 100644 index 0000000000..17291bc86f --- /dev/null +++ b/src/problem5/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": true, + "outDir": "dist", + "rootDir": ".", + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/src/problem6/README.md b/src/problem6/README.md new file mode 100644 index 0000000000..8d16cc2524 --- /dev/null +++ b/src/problem6/README.md @@ -0,0 +1,622 @@ +# Problem 6: Architecture Specification + +This document specifies a backend API module for a live leaderboard system. It is written for a backend engineering team that will implement the service. + +## Table of Contents + +- [Problem 6: Architecture Specification](#problem-6-architecture-specification) + - [Table of Contents](#table-of-contents) + - [1. Scope](#1-scope) + - [2. High-Level Architecture](#2-high-level-architecture) + - [3. High-Level Architecture Diagram](#3-high-level-architecture-diagram) + - [4. Functional Requirements](#4-functional-requirements) + - [5. Data Model](#5-data-model) + - [6. API Specification](#6-api-specification) + - [6.1 Login](#61-login) + - [6.2 Query Leaderboard](#62-query-leaderboard) + - [6.3 List Available Tasks](#63-list-available-tasks) + - [6.4 List User Tasks By Status](#64-list-user-tasks-by-status) + - [6.5 Request Action](#65-request-action) + - [6.6 Submit Action Completion](#66-submit-action-completion) + - [6.7 Get User Profile \& Score](#67-get-user-profile--score) + - [7. WebSocket Specification](#7-websocket-specification) + - [7.1 `leaderboard:event`](#71-leaderboardevent) + - [7.2 `user:event`](#72-userevent) + - [7.3 `user_task:event`](#73-user_taskevent) + - [8. Execution Flow Diagrams](#8-execution-flow-diagrams) + - [9. Security \& Authorization](#9-security--authorization) + - [JWT](#jwt) + - [Password Storage](#password-storage) + - [Score Update Protection](#score-update-protection) + - [Rate Limiting](#rate-limiting) + - [Additional Controls](#additional-controls) + - [10. Non-Functional Expectations](#10-non-functional-expectations) + - [11. Suggested Implementation Notes](#11-suggested-implementation-notes) + - [12. Future Improvements](#12-future-improvements) + +## 1. Scope + +The module supports: + +- user authentication with JWT; +- task discovery and task execution requests; +- score updates after a task is completed; +- prevention of duplicate task requests for the same user and task; +- prevention of duplicate task completion and duplicate score awards; +- live leaderboard updates through WebSocket; +- protection against unauthorized score changes. + +Out of scope: + +- the internal business logic of how a user completes a task; +- frontend implementation details; +- admin tooling. + +## 2. High-Level Architecture + +The application server is intentionally split into two logical services inside the backend API: + +- `Auth Service`: validates credentials, issues JWT, validates JWT for HTTP and WebSocket connections. +- `Score Service`: serves leaderboard/task endpoints, validates task actions, updates scores, refreshes cache, and emits real-time events. + +Supporting infrastructure: + +- `MySQL`: source of truth for users, tasks, and user task state. +- `Redis`: distributed rate limiting and cached top-10 leaderboard. +- `WebSocket Gateway`: authenticated real-time channel for leaderboard, user, and user-task events. + +## 3. High-Level Architecture Diagram + +PlantUML source: [high-level-architecture.puml](./diagram/high-level-architecture.puml) + +![High-Level Architecture](./diagram/high-level-architecture.png) + +## 4. Functional Requirements + +1. The website shows the top users by score, defaulting to top 10. +2. The leaderboard must update live without requiring manual refresh. +3. A user can request an available task/action. +4. Completing that action triggers an API call that may increase the user score. +5. Unauthorized or malicious score updates must be rejected. + +## 5. Data Model + +Recommended relational schema: + +```sql +CREATE TABLE users ( + id CHAR(36) PRIMARY KEY DEFAULT (UUID()), + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + nick_name VARCHAR(50) UNIQUE NOT NULL, + score BIGINT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE tasks ( + id CHAR(36) PRIMARY KEY DEFAULT (UUID()), + name VARCHAR(255) NOT NULL, + description TEXT, + status SMALLINT NOT NULL DEFAULT 1, -- 0 inactive, 1 active + score BIGINT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE user_tasks ( + id CHAR(36) PRIMARY KEY DEFAULT (UUID()), + user_id CHAR(36) NOT NULL, + task_id CHAR(36) NOT NULL, + status SMALLINT NOT NULL DEFAULT 1, -- 1 inprogress, 2 complete, 9 expired + score BIGINT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_user_tasks_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_user_tasks_task FOREIGN KEY (task_id) REFERENCES tasks(id) +); + +CREATE UNIQUE INDEX ux_user_tasks_user_task + ON user_tasks(user_id, task_id); + +CREATE INDEX ix_users_score_desc + ON users(score DESC, updated_at DESC); + +CREATE INDEX ix_tasks_status_updated_at + ON tasks(status, updated_at DESC); + +CREATE INDEX ix_user_tasks_user_status_updated_at + ON user_tasks(user_id, status, updated_at DESC); +``` + +Notes: + +- `users.score` is the current aggregated score used for ranking. +- `tasks.score` is the amount added to the user score when the task is successfully completed. +- `user_tasks.score` stores the awarded score snapshot for audit/debugging after completion. +- `user_tasks(user_id, task_id)` must remain unique to stop duplicate claims for the same task. + +## 6. API Specification + +All protected endpoints require: + +```http +Authorization: Bearer +``` + +### 6.1 Login + +`POST /auth/login` + +Request: + +```json +{ + "email": "player1@example.com", + "password": "user-input-password" +} +``` + +Response: + +```json +{ + "success": true, + "data": { + "token": "jwt_token", + "expiresIn": 3600, + "user": { + "userId": "uuid-123", + "nick_name": "player1", + "email": "player1@example.com", + "score": 15000 + } + } +} +``` + +Behavior: + +- receive the user's password over TLS and compare it against the stored password hash; +- issue signed JWT containing `sub`, `email`, `nick_name`; +- reject invalid credentials with `401 INVALID_CREDENTIALS`. + +**Note:** **Passwords must never be stored or transmitted as reusable plaintext outside the immediate login request. The persistence layer should store only a password hash, and the recommended hashing algorithms are `Argon2id` or `bcrypt`, not MD5 or reversible encryption.** + +### 6.2 Query Leaderboard + +`GET /leaderboard?top=xx` + +Rules: + +- default `top = 10`; +- maximum `top = 100`; +- sort by `score DESC`, then `updated_at ASC` or `id ASC` for deterministic ranking; +- prefer Redis cache for top 10, fallback to MySQL for non-default `top`. + +Response: + +```json +{ + "success": true, + "data": { + "leaderboard": [ + { + "rank": 1, + "userId": "uuid-123", + "nick_name": "player1", + "email": "player1@example.com", + "score": 15000 + }, + { + "rank": 2, + "userId": "uuid-456", + "nick_name": "player2", + "email": "player2@example.com", + "score": 12500 + } + ], + "lastUpdated": "2026-01-25T10:30:00Z" + } +} +``` + +Errors: + +- `400 INVALID_TOP_LIMIT` when `top < 1` or `top > 100`; +- `401 UNAUTHORIZED` when JWT is missing or invalid. + +### 6.3 List Available Tasks + +`GET /tasks?page=1&limit=20` + +Behavior: + +- return active tasks not yet present in `user_tasks` for the authenticated user; +- sort by `tasks.updated_at DESC`; +- support pagination with `page` and `limit`; +- default `page = 1`, default `limit = 20`, maximum `limit = 100`. + +Response: + +```json +{ + "success": true, + "data": { + "page": 1, + "limit": 20, + "total": 1, + "items": [ + { + "taskId": "uuid-task-1", + "name": "Test action", + "description": "A test description", + "status": 1, + "score": 100, + "updatedAt": "2026-01-25T10:30:00Z" + } + ] + } +} +``` + +### 6.4 List User Tasks By Status + +`GET /user-tasks?status=xx&page=1&limit=20` + +Behavior: + +- query `user_tasks` for the authenticated user; +- optional `status` filter; +- sort by `updated_at DESC`; +- support pagination with `page` and `limit`; +- default `page = 1`, default `limit = 20`, maximum `limit = 100`. + +Response: + +```json +{ + "success": true, + "data": { + "page": 1, + "limit": 20, + "total": 1, + "items": [ + { + "userTaskId": "uuid-user-task-1", + "taskId": "uuid-task-1", + "status": 1, + "score": 0, + "updatedAt": "2026-01-25T10:30:00Z" + } + ] + } +} +``` + +### 6.5 Request Action + +`POST /task/request-action/:task_id` + +Request: + +```json +{ + "action_type": "start_action" +} +``` + +Validation: + +- verify `task_id` exists in `tasks`; +- verify task is active; +- verify `(user_id, task_id)` does not already exist in `user_tasks`; +- enforce one active/requested record per `(user_id, task_id)` so the same task cannot be started twice by the same user; +- reject bad `action_type`. + +Success behavior: + +- insert a new `user_tasks` row with `status = 1` (`inprogress`); +- return inserted `user_tasks`; +- emit `user_task:event`. + +Duplicate-prevention requirements: + +- the service must reject duplicate start requests for the same `(user_id, task_id)`; +- the unique index on `user_tasks(user_id, task_id)` is the final database guardrail; +- the API should still handle race conditions explicitly and translate unique-key conflicts into a business error response instead of a generic `500`. + +Suggested errors: + +- `404 TASK_NOT_FOUND` +- `409 TASK_ALREADY_REQUESTED` +- `409 TASK_INACTIVE` +- `409 TASK_DUPLICATE_REQUEST` +- `400 INVALID_ACTION_TYPE` + +### 6.6 Submit Action Completion + +`POST /task/submit-action/:user_task_id` + +Request: + +```json +{ + "action_type": "complete_action" +} +``` + +Validation: + +- verify `user_task_id` exists; +- verify the row belongs to the authenticated user; +- verify current status is `1` (`inprogress`); +- reject any completion attempt for a task that is already `complete` or otherwise no longer eligible for scoring; +- reject invalid `action_type`. + +Success behavior: + +1. start a database transaction; +2. set `user_tasks.status = 2`; +3. copy awarded score into `user_tasks.score`; +4. increment `users.score` atomically by `tasks.score`; +5. commit transaction; +6. refresh top-10 leaderboard cache in Redis; +7. if the top 10 changed, broadcast `leaderboard:event`; +8. emit `user:event` and `user_task:event`; +9. return updated `user_tasks` and user profile. + +Duplicate-prevention requirements: + +- task completion must be idempotent from the scoring perspective: the same `user_task` must never award points more than once; +- the transaction must lock/read the current `user_tasks` row and abort if the status is no longer `inprogress`; +- if two completion requests arrive concurrently, only one may commit the score update, and the other must receive a deterministic conflict error; +- leaderboard cache refresh and WebSocket emission must happen only after the single successful completion commit. + +Multi-instance handling in detail: + +1. each API service instance may receive the same completion request at nearly the same time, so duplicate protection cannot rely on in-memory state; +2. the source of truth must be the shared MySQL database, using a single transaction on the same `user_tasks` row; +3. inside the transaction, the service should run `SELECT ... FOR UPDATE` on the target `user_tasks` record so only one API service instance can hold the row lock at a time; +4. the first API service instance that acquires the lock re-checks `status = 1` (`inprogress`), updates `user_tasks.status = 2`, writes `user_tasks.score`, and increments `users.score`; +5. any other API service instance waits for the lock, then reads the already-updated row, sees that `status != 1`, and must return a conflict such as `409 USER_TASK_ALREADY_COMPLETED` or `409 USER_TASK_DUPLICATE_COMPLETION`; +6. because the score increment happens in the same transaction as the status change, only one API service instance can ever commit the score award; +7. Redis cache refresh and WebSocket broadcasting must happen only after the winning transaction commits successfully, so downstream systems also observe a single completion event. + +Recommended transaction outline: + +```sql +BEGIN; + +SELECT id, user_id, task_id, status, score +FROM user_tasks +WHERE id = ? +FOR UPDATE; + +-- Application checks: +-- 1. row exists +-- 2. row belongs to authenticated user +-- 3. status = 1 (inprogress) + +SELECT id, score +FROM tasks +WHERE id = ? + AND status = 1; + +UPDATE user_tasks +SET status = 2, + score = ? +WHERE id = ? + AND status = 1; + +UPDATE users +SET score = score + ? +WHERE id = ?; + +COMMIT; +``` + +Recommended API-side safeguards in addition to row locking: + +- attach an idempotency key or request ID to the completion request for safer retry behavior across gateways, timeouts, or client retries; +- log the winning completion request ID together with `user_task_id`; +- if `UPDATE user_tasks ... WHERE status = 1` affects `0` rows, treat it as a duplicate/already-completed request instead of retrying blindly; +- never publish leaderboard or user events before the database commit succeeds. + +Success response: + +```json +{ + "success": true, + "data": { + "userTask": { + "userTaskId": "uuid-user-task-1", + "taskId": "uuid-task-1", + "status": 2, + "score": 100 + }, + "user": { + "userId": "uuid-123", + "nick_name": "player1", + "email": "player1@example.com", + "score": 15100 + } + } +} +``` + +Suggested errors: + +- `404 USER_TASK_NOT_FOUND` +- `403 USER_TASK_FORBIDDEN` +- `409 USER_TASK_INVALID_STATUS` +- `409 USER_TASK_ALREADY_COMPLETED` +- `409 USER_TASK_DUPLICATE_COMPLETION` +- `400 INVALID_ACTION_TYPE` + +### 6.7 Get User Profile & Score + +`GET /users/me` + +Response: + +```json +{ + "success": true, + "data": { + "userId": "uuid-123", + "nick_name": "player1", + "email": "player1@example.com", + "score": 15100, + "createdAt": "2026-01-25T10:00:00Z", + "updatedAt": "2026-01-25T10:35:00Z" + } +} +``` + +## 7. WebSocket Specification + +Connection: + +```ts +const socket = io("wss://xxx.xxx", { + auth: { + token: "jwt_token" + } +}); +``` + +Authentication: + +- the WebSocket gateway verifies the JWT during handshake; +- if verification fails, the connection is rejected; +- the authenticated `userId` becomes the room/channel key for user-scoped events. + +Events: + +### 7.1 `leaderboard:event` + +Broadcast only when cached top 10 changes. + +```json +{ + "leaderboard": [ + { + "rank": 1, + "userId": "uuid-123", + "nick_name": "player1", + "email": "player1@example.com", + "score": 15100 + } + ], + "updatedAt": "2026-01-25T10:33:00Z" +} +``` + +### 7.2 `user:event` + +Emit only to the affected user. + +```json +{ + "userId": "uuid-123", + "nick_name": "player1", + "email": "player1@example.com", + "score": 5700, + "rank": 15 +} +``` + +### 7.3 `user_task:event` + +Emit only to the affected user. + +```json +{ + "userId": "uuid-123", + "taskId": "uuid-task-1", + "status": 2, + "task": { + "name": "Test action", + "description": "A test description", + "status": 1, + "score": 100 + } +} +``` + +## 8. Execution Flow Diagrams + +PlantUML source: [execution-flow.puml](./diagram/execution-flow.puml) + +![Execution Flow](./diagram/execution-flow.png) + +End-to-end flow: + +1. User logs in and receives a JWT. +2. Client opens WebSocket connection with that JWT. +3. Client loads available tasks through `GET /tasks`. +4. User chooses a task and requests it through `POST /task/request-action/:task_id`. +5. Service creates an `inprogress` record in `user_tasks`. +6. Client completes the task and submits `POST /task/submit-action/:user_task_id`. +7. Score service validates ownership and current status. +8. Score service updates `user_tasks` and `users.score` inside one transaction. +9. Service refreshes top-10 leaderboard cache. +10. If the top 10 changes, the service broadcasts `leaderboard:event`. +11. Service also emits `user:event` and `user_task:event` to the affected user. + +## 9. Security & Authorization + +### JWT + +- login issues a signed JWT using a strong server-side secret or asymmetric key pair; +- JWT payload should include `sub` as the user ID plus minimal identity claims; +- JWT expiration should be short-lived, for example 15 to 60 minutes; +- every protected HTTP request and WebSocket connection must verify signature, expiration, and subject. + +### Password Storage + +- store password hashes only, never plain text; +- use Argon2id or bcrypt with current secure cost parameters. + +### Score Update Protection + +- never allow direct score update endpoints from the client; +- only `submit-action` can increase score; +- `submit-action` must validate task ownership, task status, and current user identity; +- the transaction must update score exactly once for each `user_task`. + +### Rate Limiting + +- use Redis-backed distributed global rate limiting so limits hold across multiple API instances; +- apply stricter limits to login and task submission endpoints; +- recommended dimensions: `ip`, `userId`, and route key; +- return `429 TOO_MANY_REQUESTS` when the limit is exceeded. + +### Additional Controls + +- audit every rejected completion attempt with user ID, task ID, and reason; +- validate UUID input and enum/status input strictly; +- use idempotency protection for completion requests where feasible; +- use TLS for HTTP and WebSocket traffic. + +## 10. Non-Functional Expectations + +- leaderboard read latency should remain low for top-10 queries because Redis serves the hot path; +- score updates must be atomic and consistent under concurrent submissions; +- event payloads should be small and stable because they fan out to many clients; +- API responses should always use deterministic error codes for client handling. + +## 11. Suggested Implementation Notes + +- prefer MySQL transactions with `SELECT ... FOR UPDATE` or equivalent row locking around `user_tasks` and `users`; +- keep the leaderboard cache write-through or refresh-on-change, not periodic-only; +- centralize auth logic so HTTP and WebSocket use the same JWT verification function; +- keep WebSocket broadcasting in a dedicated gateway/adapter instead of embedding socket logic in controllers. + +## 12. Future Improvements + +- add refresh tokens and token revocation for stronger session control; +- add task expiration rules and scheduled cleanup for stale `inprogress` rows; +- add audit/event tables if fraud investigation becomes important; +- add observability around transaction retries, cache hit rate, and WebSocket delivery failures. diff --git a/src/problem6/diagram/execution-flow.png b/src/problem6/diagram/execution-flow.png new file mode 100644 index 0000000000..520a0ae215 Binary files /dev/null and b/src/problem6/diagram/execution-flow.png differ diff --git a/src/problem6/diagram/execution-flow.puml b/src/problem6/diagram/execution-flow.puml new file mode 100644 index 0000000000..0ccc3d9ae6 --- /dev/null +++ b/src/problem6/diagram/execution-flow.puml @@ -0,0 +1,53 @@ +@startuml +title Live Leaderboard Score Update Flow + +actor User +participant "Web Client" as Client +participant "Auth Service" as Auth +participant "Score Service" as Score +database "MySQL" as DB +collections "Redis Cache" as Redis +queue "WebSocket Gateway" as WS + +User -> Client: Login +Client -> Auth: POST /auth/login +Auth -> DB: Validate user credentials +DB --> Auth: User row +Auth --> Client: JWT access token + +User -> Client: Open task list +Client -> Score: GET /tasks\nAuthorization: Bearer JWT +Score -> Auth: Verify JWT +Auth --> Score: Authenticated user context +Score -> DB: Query active tasks not yet requested +DB --> Score: Task rows +Score --> Client: Available tasks + +User -> Client: Start task +Client -> Score: POST /task/request-action/:task_id +Score -> Auth: Verify JWT +Auth --> Score: Authenticated user context +Score -> DB: Validate task + upsert user_tasks +DB --> Score: user_tasks row (inprogress) +Score --> Client: user_tasks payload +Score --> WS: Emit user_task:event + +User -> Client: Submit completion +Client -> Score: POST /task/submit-action/:user_task_id +Score -> Auth: Verify JWT +Auth --> Score: Authenticated user context +Score -> DB: Validate ownership + status +Score -> DB: Atomic transaction\nupdate user_tasks + user.score +DB --> Score: Updated task + user +Score -> Redis: Refresh cached top 10 leaderboard +Redis --> Score: Previous vs current top 10 + +alt Top 10 changed + Score --> WS: Broadcast leaderboard:event +end + +Score --> WS: Emit user:event +Score --> WS: Emit user_task:event +Score --> Client: Updated user task + user score + +@enduml diff --git a/src/problem6/diagram/high-level-architecture.png b/src/problem6/diagram/high-level-architecture.png new file mode 100644 index 0000000000..5133230cd0 Binary files /dev/null and b/src/problem6/diagram/high-level-architecture.png differ diff --git a/src/problem6/diagram/high-level-architecture.puml b/src/problem6/diagram/high-level-architecture.puml new file mode 100644 index 0000000000..4026a9170c --- /dev/null +++ b/src/problem6/diagram/high-level-architecture.puml @@ -0,0 +1,34 @@ +@startuml +title High-Level Architecture + +skinparam componentStyle rectangle + +actor User +rectangle "Client" { + component "Web App" as WebApp +} + +rectangle "Application Server" { + component "Auth Service" as Auth + component "Score Service" as Score +} + +database "MySQL" as DB +collections "Redis" as Redis +queue "WebSocket" as WS + +User --> WebApp +WebApp --> Auth : Login / JWT +WebApp --> Score : REST API +WebApp <--> WS : Live events + +Auth --> DB : Users +Auth --> Redis : Rate limit/session data + +Score --> DB : Users / Tasks / UserTasks +Score --> Redis : Top 10 cache / rate limit +Score --> WS : Broadcast updates + +Auth --> Score : Verified user context + +@enduml