diff --git a/src/problem4/index.ts b/src/problem4/index.ts new file mode 100644 index 0000000000..5b6432aab0 --- /dev/null +++ b/src/problem4/index.ts @@ -0,0 +1,44 @@ +/** + * Problem 4: Three ways to sum to n + * + * Contract used for any integer n: + * - n > 0: 1 + 2 + ... + n + * - n < 0: -1 + -2 + ... + n + * - n = 0: 0 + */ + +export function sum_to_n_a(n: number): number { + // Arithmetic formula. + // Time: O(1), Space: O(1). This is the most efficient implementation. + const sign = n < 0 ? -1 : 1; + const absoluteN = Math.abs(n); + + return sign * ((absoluteN * (absoluteN + 1)) / 2); +} + +export function sum_to_n_b(n: number): number { + // Iterative accumulation. + // Time: O(|n|), Space: O(1). Simple and memory-efficient, but slower for large |n|. + let total = 0; + const step = n < 0 ? -1 : 1; + + for (let current = step; n < 0 ? current >= n : current <= n; current += step) { + total += current; + } + + return total; +} + +export function sum_to_n_c(n: number): number { + // Functional implementation using array generation and reduce. + // Time: O(|n|), Space: O(|n|). Clear as a demonstration, but less efficient + // because it allocates an array with one element per number being summed. + const sign = n < 0 ? -1 : 1; + const absoluteN = Math.abs(n); + + return Array.from({ length: absoluteN }, (_, index) => sign * (index + 1)).reduce( + (total, value) => total + value, + 0, + ); +} + diff --git a/src/problem5/README.md b/src/problem5/README.md new file mode 100644 index 0000000000..be7b83ad18 --- /dev/null +++ b/src/problem5/README.md @@ -0,0 +1,113 @@ +# Problem 5: A Crude Server + +This is a small ExpressJS + TypeScript CRUD server for managing books. + +The service persists data to a JSON database file at `data/database.json` by default. You can override that path with `DATABASE_FILE`. + +## Requirements + +- Node.js 20+ +- npm + +## Install + +From the repository root: + +```bash +cd backend_test/problem5 +npm install +``` + +## Run In Development + +```bash +npm run dev +``` + +The server starts on `http://localhost:3000`. + +To use another port: + +```bash +PORT=4000 npm run dev +``` + +To use another database file: + +```bash +DATABASE_FILE=./data/local.json npm run dev +``` + +## Build And Run + +```bash +npm run build +npm start +``` + +## API + +### Health Check + +```bash +curl http://localhost:3000/health +``` + +### Create A Book + +```bash +curl -X POST http://localhost:3000/books \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Clean Code", + "author": "Robert C. Martin", + "status": "available", + "publishedYear": 2008 + }' +``` + +Fields: + +- `title`: required string +- `author`: required string +- `status`: optional, one of `available`, `borrowed`, `archived` +- `publishedYear`: optional integer + +### List Books + +```bash +curl http://localhost:3000/books +``` + +Basic filters: + +```bash +curl "http://localhost:3000/books?status=available" +curl "http://localhost:3000/books?author=martin" +curl "http://localhost:3000/books?q=clean" +``` + +### Get Book Details + +```bash +curl http://localhost:3000/books/:id +``` + +### Update A Book + +```bash +curl -X PATCH http://localhost:3000/books/:id \ + -H "Content-Type: application/json" \ + -d '{ + "status": "borrowed" + }' +``` + +### Delete A Book + +```bash +curl -X DELETE http://localhost:3000/books/:id +``` + +Successful deletes return `204 No Content`. + diff --git a/src/problem5/dist/app.js b/src/problem5/dist/app.js new file mode 100644 index 0000000000..094b36402d --- /dev/null +++ b/src/problem5/dist/app.js @@ -0,0 +1,95 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.app = void 0; +const cors_1 = __importDefault(require("cors")); +const express_1 = __importDefault(require("express")); +const helmet_1 = __importDefault(require("helmet")); +const morgan_1 = __importDefault(require("morgan")); +const zod_1 = require("zod"); +const bookRepository_1 = require("./bookRepository"); +const validation_1 = require("./validation"); +exports.app = (0, express_1.default)(); +exports.app.use((0, helmet_1.default)()); +exports.app.use((0, cors_1.default)()); +exports.app.use(express_1.default.json()); +exports.app.use((0, morgan_1.default)("dev")); +exports.app.get("/health", (_req, res) => { + res.json({ status: "ok" }); +}); +exports.app.post("/books", async (req, res, next) => { + try { + const input = validation_1.createBookSchema.parse(req.body); + const book = await (0, bookRepository_1.createBook)(input); + res.status(201).json({ data: book }); + } + catch (error) { + next(error); + } +}); +exports.app.get("/books", async (req, res, next) => { + try { + const filters = validation_1.listBookQuerySchema.parse(req.query); + const books = await (0, bookRepository_1.listBooks)(filters); + res.json({ data: books, count: books.length }); + } + catch (error) { + next(error); + } +}); +exports.app.get("/books/:id", async (req, res, next) => { + try { + const book = await (0, bookRepository_1.getBookById)(req.params.id); + if (!book) { + res.status(404).json({ error: "Book not found" }); + return; + } + res.json({ data: book }); + } + catch (error) { + next(error); + } +}); +exports.app.patch("/books/:id", async (req, res, next) => { + try { + const input = validation_1.updateBookSchema.parse(req.body); + const book = await (0, bookRepository_1.updateBook)(req.params.id, input); + if (!book) { + res.status(404).json({ error: "Book not found" }); + return; + } + res.json({ data: book }); + } + catch (error) { + next(error); + } +}); +exports.app.delete("/books/:id", async (req, res, next) => { + try { + const deleted = await (0, bookRepository_1.deleteBook)(req.params.id); + if (!deleted) { + res.status(404).json({ error: "Book not found" }); + return; + } + res.status(204).send(); + } + catch (error) { + next(error); + } +}); +exports.app.use((_req, res) => { + res.status(404).json({ error: "Route not found" }); +}); +exports.app.use((error, _req, res, _next) => { + if (error instanceof zod_1.ZodError) { + res.status(400).json({ + error: "Validation failed", + details: error.flatten(), + }); + return; + } + console.error(error); + res.status(500).json({ error: "Internal server error" }); +}); diff --git a/src/problem5/dist/bookRepository.js b/src/problem5/dist/bookRepository.js new file mode 100644 index 0000000000..ea21b1e967 --- /dev/null +++ b/src/problem5/dist/bookRepository.js @@ -0,0 +1,71 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.listBooks = listBooks; +exports.getBookById = getBookById; +exports.createBook = createBook; +exports.updateBook = updateBook; +exports.deleteBook = deleteBook; +const crypto_1 = require("crypto"); +const database_1 = require("./database"); +async function listBooks(filters) { + const data = await database_1.database.read(); + const author = filters.author?.toLowerCase(); + const query = filters.q?.toLowerCase(); + return data.books.filter((book) => { + if (filters.status && book.status !== filters.status) { + return false; + } + if (author && !book.author.toLowerCase().includes(author)) { + return false; + } + if (query) { + const searchable = `${book.title} ${book.author}`.toLowerCase(); + if (!searchable.includes(query)) { + return false; + } + } + return true; + }); +} +async function getBookById(id) { + const data = await database_1.database.read(); + return data.books.find((book) => book.id === id) ?? null; +} +async function createBook(input) { + const data = await database_1.database.read(); + const now = new Date().toISOString(); + const book = { + id: (0, crypto_1.randomUUID)(), + ...input, + createdAt: now, + updatedAt: now, + }; + data.books.push(book); + await database_1.database.write(data); + return book; +} +async function updateBook(id, input) { + const data = await database_1.database.read(); + const index = data.books.findIndex((book) => book.id === id); + if (index === -1) { + return null; + } + const updatedBook = { + ...data.books[index], + ...input, + updatedAt: new Date().toISOString(), + }; + data.books[index] = updatedBook; + await database_1.database.write(data); + return updatedBook; +} +async function deleteBook(id) { + const data = await database_1.database.read(); + const nextBooks = data.books.filter((book) => book.id !== id); + if (nextBooks.length === data.books.length) { + return false; + } + data.books = nextBooks; + await database_1.database.write(data); + return true; +} diff --git a/src/problem5/dist/database.js b/src/problem5/dist/database.js new file mode 100644 index 0000000000..2dbbebf4bf --- /dev/null +++ b/src/problem5/dist/database.js @@ -0,0 +1,38 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.database = exports.JsonDatabase = void 0; +const fs_1 = require("fs"); +const path_1 = __importDefault(require("path")); +const defaultDatabase = { + books: [], +}; +class JsonDatabase { + constructor(filePath) { + this.filePath = filePath; + } + async read() { + await this.ensureFile(); + const content = await fs_1.promises.readFile(this.filePath, "utf8"); + return JSON.parse(content); + } + async write(data) { + await fs_1.promises.mkdir(path_1.default.dirname(this.filePath), { recursive: true }); + await fs_1.promises.writeFile(this.filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); + } + async ensureFile() { + try { + await fs_1.promises.access(this.filePath); + } + catch { + await this.write(defaultDatabase); + } + } +} +exports.JsonDatabase = JsonDatabase; +const databasePath = process.env.DATABASE_FILE + ? path_1.default.resolve(process.env.DATABASE_FILE) + : path_1.default.resolve(__dirname, "../data/database.json"); +exports.database = new JsonDatabase(databasePath); diff --git a/src/problem5/dist/server.js b/src/problem5/dist/server.js new file mode 100644 index 0000000000..dfc0788c6e --- /dev/null +++ b/src/problem5/dist/server.js @@ -0,0 +1,7 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const app_1 = require("./app"); +const port = Number(process.env.PORT ?? 3000); +app_1.app.listen(port, () => { + console.log(`Problem 5 server is running on http://localhost:${port}`); +}); diff --git a/src/problem5/dist/types.js b/src/problem5/dist/types.js new file mode 100644 index 0000000000..c8ad2e549b --- /dev/null +++ b/src/problem5/dist/types.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/src/problem5/dist/validation.js b/src/problem5/dist/validation.js new file mode 100644 index 0000000000..02ae472a84 --- /dev/null +++ b/src/problem5/dist/validation.js @@ -0,0 +1,17 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.listBookQuerySchema = exports.updateBookSchema = exports.createBookSchema = exports.bookStatusSchema = void 0; +const zod_1 = require("zod"); +exports.bookStatusSchema = zod_1.z.enum(["available", "borrowed", "archived"]); +exports.createBookSchema = zod_1.z.object({ + title: zod_1.z.string().trim().min(1, "title is required"), + author: zod_1.z.string().trim().min(1, "author is required"), + status: exports.bookStatusSchema.default("available"), + publishedYear: zod_1.z.number().int().min(0).max(9999).optional(), +}); +exports.updateBookSchema = exports.createBookSchema.partial().refine((value) => Object.keys(value).length > 0, "at least one field is required"); +exports.listBookQuerySchema = zod_1.z.object({ + status: exports.bookStatusSchema.optional(), + author: zod_1.z.string().trim().optional(), + q: zod_1.z.string().trim().optional(), +}); diff --git a/src/problem5/package.json b/src/problem5/package.json new file mode 100644 index 0000000000..ee66d30d98 --- /dev/null +++ b/src/problem5/package.json @@ -0,0 +1,26 @@ +{ + "name": "problem5-crude-server", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "ts-node-dev --respawn --transpile-only src/server.ts", + "build": "tsc", + "start": "node dist/server.js" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.3", + "helmet": "^7.1.0", + "morgan": "^1.10.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/morgan": "^1.9.9", + "@types/node": "^20.11.19", + "ts-node-dev": "^2.0.0", + "typescript": "^5.3.3" + } +} + diff --git a/src/problem5/src/app.ts b/src/problem5/src/app.ts new file mode 100644 index 0000000000..609ab1e443 --- /dev/null +++ b/src/problem5/src/app.ts @@ -0,0 +1,110 @@ +import cors from "cors"; +import express, { NextFunction, Request, Response } from "express"; +import helmet from "helmet"; +import morgan from "morgan"; +import { ZodError } from "zod"; +import { + createBook, + deleteBook, + getBookById, + listBooks, + updateBook, +} from "./bookRepository"; +import { createBookSchema, listBookQuerySchema, updateBookSchema } from "./validation"; + +export const app = express(); + +app.use(helmet()); +app.use(cors()); +app.use(express.json()); +app.use(morgan("dev")); + +app.get("/health", (_req, res) => { + res.json({ status: "ok" }); +}); + +app.post("/books", async (req, res, next) => { + try { + const input = createBookSchema.parse(req.body); + const book = await createBook(input); + + res.status(201).json({ data: book }); + } catch (error) { + next(error); + } +}); + +app.get("/books", async (req, res, next) => { + try { + const filters = listBookQuerySchema.parse(req.query); + const books = await listBooks(filters); + + res.json({ data: books, count: books.length }); + } catch (error) { + next(error); + } +}); + +app.get("/books/:id", async (req, res, next) => { + try { + const book = await getBookById(req.params.id); + + if (!book) { + res.status(404).json({ error: "Book not found" }); + return; + } + + res.json({ data: book }); + } catch (error) { + next(error); + } +}); + +app.patch("/books/:id", async (req, res, next) => { + try { + const input = updateBookSchema.parse(req.body); + const book = await updateBook(req.params.id, input); + + if (!book) { + res.status(404).json({ error: "Book not found" }); + return; + } + + res.json({ data: book }); + } catch (error) { + next(error); + } +}); + +app.delete("/books/:id", async (req, res, next) => { + try { + const deleted = await deleteBook(req.params.id); + + if (!deleted) { + res.status(404).json({ error: "Book not found" }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +app.use((_req, res) => { + res.status(404).json({ error: "Route not found" }); +}); + +app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => { + if (error instanceof ZodError) { + res.status(400).json({ + error: "Validation failed", + details: error.flatten(), + }); + return; + } + + console.error(error); + res.status(500).json({ error: "Internal server error" }); +}); + diff --git a/src/problem5/src/bookRepository.ts b/src/problem5/src/bookRepository.ts new file mode 100644 index 0000000000..9e754cfd3f --- /dev/null +++ b/src/problem5/src/bookRepository.ts @@ -0,0 +1,99 @@ +import { randomUUID } from "crypto"; +import { database } from "./database"; +import { Book, BookStatus } from "./types"; + +interface ListBooksFilters { + status?: BookStatus; + author?: string; + q?: string; +} + +interface CreateBookInput { + title: string; + author: string; + status: BookStatus; + publishedYear?: number; +} + +type UpdateBookInput = Partial; + +export async function listBooks(filters: ListBooksFilters): Promise { + const data = await database.read(); + const author = filters.author?.toLowerCase(); + const query = filters.q?.toLowerCase(); + + return data.books.filter((book) => { + if (filters.status && book.status !== filters.status) { + return false; + } + + if (author && !book.author.toLowerCase().includes(author)) { + return false; + } + + if (query) { + const searchable = `${book.title} ${book.author}`.toLowerCase(); + if (!searchable.includes(query)) { + return false; + } + } + + return true; + }); +} + +export async function getBookById(id: string): Promise { + const data = await database.read(); + return data.books.find((book) => book.id === id) ?? null; +} + +export async function createBook(input: CreateBookInput): Promise { + const data = await database.read(); + const now = new Date().toISOString(); + const book: Book = { + id: randomUUID(), + ...input, + createdAt: now, + updatedAt: now, + }; + + data.books.push(book); + await database.write(data); + + return book; +} + +export async function updateBook(id: string, input: UpdateBookInput): Promise { + const data = await database.read(); + const index = data.books.findIndex((book) => book.id === id); + + if (index === -1) { + return null; + } + + const updatedBook: Book = { + ...data.books[index], + ...input, + updatedAt: new Date().toISOString(), + }; + + data.books[index] = updatedBook; + await database.write(data); + + return updatedBook; +} + +export async function deleteBook(id: string): Promise { + const data = await database.read(); + const nextBooks = data.books.filter((book) => book.id !== id); + + if (nextBooks.length === data.books.length) { + return false; + } + + data.books = nextBooks; + await database.write(data); + + return true; +} + diff --git a/src/problem5/src/database.ts b/src/problem5/src/database.ts new file mode 100644 index 0000000000..68916d4324 --- /dev/null +++ b/src/problem5/src/database.ts @@ -0,0 +1,38 @@ +import { promises as fs } from "fs"; +import path from "path"; +import { DatabaseSchema } from "./types"; + +const defaultDatabase: DatabaseSchema = { + books: [], +}; + +export class JsonDatabase { + constructor(private readonly filePath: string) {} + + async read(): Promise { + await this.ensureFile(); + + const content = await fs.readFile(this.filePath, "utf8"); + return JSON.parse(content) as DatabaseSchema; + } + + async write(data: DatabaseSchema): Promise { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + await fs.writeFile(this.filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); + } + + private async ensureFile(): Promise { + try { + await fs.access(this.filePath); + } catch { + await this.write(defaultDatabase); + } + } +} + +const databasePath = process.env.DATABASE_FILE + ? path.resolve(process.env.DATABASE_FILE) + : path.resolve(__dirname, "../data/database.json"); + +export const database = new JsonDatabase(databasePath); + diff --git a/src/problem5/src/server.ts b/src/problem5/src/server.ts new file mode 100644 index 0000000000..4339ae3238 --- /dev/null +++ b/src/problem5/src/server.ts @@ -0,0 +1,8 @@ +import { app } from "./app"; + +const port = Number(process.env.PORT ?? 3000); + +app.listen(port, () => { + console.log(`Problem 5 server is running on http://localhost:${port}`); +}); + diff --git a/src/problem5/src/types.ts b/src/problem5/src/types.ts new file mode 100644 index 0000000000..d387d22874 --- /dev/null +++ b/src/problem5/src/types.ts @@ -0,0 +1,16 @@ +export type BookStatus = "available" | "borrowed" | "archived"; + +export interface Book { + id: string; + title: string; + author: string; + status: BookStatus; + publishedYear?: number; + createdAt: string; + updatedAt: string; +} + +export interface DatabaseSchema { + books: Book[]; +} + diff --git a/src/problem5/src/validation.ts b/src/problem5/src/validation.ts new file mode 100644 index 0000000000..c316e63094 --- /dev/null +++ b/src/problem5/src/validation.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const bookStatusSchema = z.enum(["available", "borrowed", "archived"]); + +export const createBookSchema = z.object({ + title: z.string().trim().min(1, "title is required"), + author: z.string().trim().min(1, "author is required"), + status: bookStatusSchema.default("available"), + publishedYear: z.number().int().min(0).max(9999).optional(), +}); + +export const updateBookSchema = createBookSchema.partial().refine( + (value) => Object.keys(value).length > 0, + "at least one field is required", +); + +export const listBookQuerySchema = z.object({ + status: bookStatusSchema.optional(), + author: z.string().trim().optional(), + q: z.string().trim().optional(), +}); + diff --git a/src/problem5/tsconfig.json b/src/problem5/tsconfig.json new file mode 100644 index 0000000000..2aaff06a60 --- /dev/null +++ b/src/problem5/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} + diff --git a/src/problem6/README.md b/src/problem6/README.md new file mode 100644 index 0000000000..6ca197180e --- /dev/null +++ b/src/problem6/README.md @@ -0,0 +1,351 @@ +# Problem 6: Scoreboard API Module Specification + +## Overview + +This document specifies a backend module for a live scoreboard feature. + +The module is responsible for: + +- Maintaining user scores. +- Returning the current top 10 users by score. +- Updating scores after an authorized user action is completed. +- Broadcasting live scoreboard changes to connected clients. +- Preventing users from increasing scores without authorization. + +The backend engineering team can implement this module inside an existing API service or as a dedicated service behind the same API gateway. + +## Goals + +- Show the top 10 users on the website scoreboard. +- Update the scoreboard live when scores change. +- Accept score updates only when they come from a trusted, authorized action-completion flow. +- Keep score updates auditable and idempotent. + +## Non-Goals + +- Defining the user action itself. +- Building frontend UI. +- Supporting arbitrary manual score edits from users. +- Supporting historical leaderboard pages beyond the top 10. + +## Core Concepts + +### User + +An authenticated account that can appear on the scoreboard. + +### Action Completion + +A trusted event proving that a user completed a score-earning action. The scoreboard module must not trust a plain client request that says, "increase my score." The client may only request completion of an action. The backend decides whether the action is valid and how many points it is worth. + +### Score Event + +An immutable record of one authorized score increase. + +### Scoreboard Snapshot + +The current top 10 users ordered by score descending. + +## API Endpoints + +### Get Top Scoreboard + +```http +GET /api/scoreboard/top?limit=10 +Authorization: Bearer +``` + +Returns the current top 10 users. + +Response: + +```json +{ + "data": [ + { + "rank": 1, + "userId": "user_123", + "displayName": "Jane", + "score": 4200, + "updatedAt": "2026-05-03T12:00:00.000Z" + } + ] +} +``` + +Rules: + +- Sort by `score DESC`. +- Use a stable tie-breaker such as `updatedAt ASC` then `userId ASC`. +- Do not expose private user data. +- `limit` defaults to `10` and must not exceed `10` for this module. + +### Get My Scoreboard Position + +```http +GET /api/scoreboard/me +Authorization: Bearer +``` + +Returns the authenticated user's score and current rank. + +Response: + +```json +{ + "data": { + "userId": "user_123", + "rank": 7, + "score": 1500, + "updatedAt": "2026-05-03T12:00:00.000Z" + } +} +``` + +### Subscribe To Live Scoreboard Updates + +Recommended first implementation: Server-Sent Events. Use WebSockets only if the product later needs bidirectional live behavior. + +SSE option: + +```http +GET /api/scoreboard/live +Authorization: Bearer +Accept: text/event-stream +``` + +Event payload: + +```json +{ + "type": "scoreboard.updated", + "data": [ + { + "rank": 1, + "userId": "user_123", + "displayName": "Jane", + "score": 4200, + "updatedAt": "2026-05-03T12:00:00.000Z" + } + ] +} +``` + +Rules: + +- Send the latest top 10 snapshot when the connection opens. +- Broadcast a new snapshot only when the top 10 changes. +- Require authentication before opening the stream. +- Apply connection limits per user/IP. + +### Complete Action And Award Score + +```http +POST /api/actions/:actionId/complete +Authorization: Bearer +Idempotency-Key: +Content-Type: application/json +``` + +Request: + +```json +{ + "completionId": "completion_abc123", + "completionToken": "optional_signed_action_completion_token" +} +``` + +Response: + +```json +{ + "data": { + "eventId": "score_event_123", + "userId": "user_123", + "awardedPoints": 50, + "totalScore": 4250, + "rank": 4, + "createdAt": "2026-05-03T12:00:00.000Z" + } +} +``` + +Rules: + +- The authenticated user must match the user in the verified action completion data. +- `completionId` must be unique and can only be rewarded once. +- The server must load the point value from a server-owned action rule. +- The server must reject any request body containing client-provided `score`, `points`, `scoreDelta`, or `rank`. +- The operation must be atomic: create event, update score, and publish update together or not at all. +- Repeated requests with the same `Idempotency-Key` should return the original result. + +## Authorization And Abuse Prevention + +The server must not trust the client as the source of truth for score increases. + +Required checks: + +- Verify the access token and identify the authenticated user. +- Verify the action is enabled in the server-owned `score_actions` table or config. +- Verify `completionToken` with a trusted action service secret or public key when the action is completed outside this API service. +- Confirm the completion proof includes: + - `userId` + - `actionId` + - `completionId` + - `issuedAt` + - `expiresAt` +- Reject expired completion tokens. +- Reject completion tokens whose `userId` does not match the authenticated user. +- Reject duplicate `completionId` values. +- Calculate the final awarded points on the server from configured action rules. +- Rate-limit score submission attempts per user and IP. +- Log rejected attempts for abuse monitoring. + +Recommended proof token format: + +- Short-lived JWT signed by the trusted action-completion service, or +- HMAC signature over `userId`, `actionId`, `completionId`, and expiration timestamp. + +## Data Model + +### `user_scores` + +Stores the current score per user. + +| Column | Type | Notes | +|---|---|---| +| `user_id` | string / uuid | Primary key | +| `score` | integer | Non-negative total score | +| `updated_at` | timestamp | Last score update time | + +Indexes: + +- Primary key on `user_id`. +- Index on `(score DESC, updated_at ASC, user_id ASC)` for top 10 reads. + +### `score_actions` + +Stores server-owned action reward configuration. + +| Column | Type | Notes | +|---|---|---| +| `id` | string / uuid | Primary key, also used as `:actionId` | +| `name` | string | Human-readable action name | +| `points` | integer | Approved reward amount | +| `active` | boolean | Whether scoring is active | +| `max_awards_per_user` | integer / null | Optional per-user award limit | +| `created_at` | timestamp | Creation time | +| `updated_at` | timestamp | Last rule update time | + +Constraints: + +- `points > 0`. +- Only active actions can award points. + +### `score_events` + +Stores immutable score updates. + +| Column | Type | Notes | +|---|---|---| +| `id` | string / uuid | Primary key | +| `user_id` | string / uuid | User receiving score | +| `action_id` | string / uuid | Completed action type | +| `completion_id` | string | Unique action completion identifier | +| `points_awarded` | integer | Server-approved score increment | +| `idempotency_key` | string | Unique per user request | +| `metadata` | json / jsonb | Optional non-sensitive debug context | +| `created_at` | timestamp | Event creation time | + +Constraints: + +- Unique index on `completion_id`. +- Unique index on `(user_id, idempotency_key)`. +- Index on `(user_id, created_at DESC)`. +- `points_awarded > 0`. + +## Execution Flow + +```mermaid +sequenceDiagram + participant Browser as Website Browser + participant Action as Action Validator + participant API as Backend API + participant DB as Database + participant Realtime as Realtime Publisher + participant Clients as Connected Scoreboards + + Browser->>Action: Complete user action + Action->>Action: Validate completion or issue completionToken + Action-->>Browser: Return completionId and optional completionToken + Browser->>API: POST /api/actions/:actionId/complete + API->>API: Authenticate access token + API->>API: Validate action and completion ownership + API->>DB: Begin transaction + API->>DB: Check completionId and idempotency key + API->>DB: Load score_actions.points + API->>DB: Insert score_event + API->>DB: Increment user_scores.score + API->>DB: Query top 10 scoreboard + API->>DB: Commit transaction + API->>Realtime: Publish scoreboard.updated if top 10 changed + Realtime-->>Clients: Push latest top 10 snapshot + API-->>Browser: Return new score result +``` + +## Implementation Notes + +Score update transaction: + +1. Start database transaction. +2. Check whether `(userId, idempotencyKey)` already exists. +3. If it exists, return the stored result without applying another score increment. +4. Verify `completionId` has not already been used. +5. Load the enabled `score_actions` row for `actionId`. +6. Insert `score_events`. +7. Upsert and increment `user_scores`. +8. Query top 10. +9. Commit. +10. Publish realtime update after commit. + +Realtime delivery: + +- Prefer publishing after the database commit so clients never see rolled-back data. +- Use Redis Pub/Sub, a message queue, or the platform event bus when the API runs multiple instances. +- If using SSE, keep events small and send full top 10 snapshots instead of partial patches. + +Error responses: + +| Status | Reason | +|---|---| +| `400` | Invalid request body | +| `401` | Missing or invalid access token | +| `403` | Action completion does not belong to authenticated user | +| `404` | Action not found | +| `409` | Duplicate completion already rewarded | +| `422` | Action is valid but not scoreable | +| `429` | Rate limit exceeded | +| `500` | Unexpected server error | + +## Security Requirements + +- Never accept a raw score increment from an untrusted client. +- Never accept `score`, `points`, `scoreDelta`, or `rank` from an untrusted client. +- Keep signing keys outside source control. +- Rotate completion-token signing keys. +- Use short completion-token expiry, for example 1 to 5 minutes. +- Validate all request bodies with a schema validator. +- Use HTTPS only. +- Add audit logs for accepted and rejected score attempts. +- Alert on high rejection rates, repeated duplicate completions, and unusual score velocity. + +## Additional Improvements + +- Add a background reconciliation job that rebuilds `user_scores` from `score_events` and reports drift. +- Cache the top 10 scoreboard in Redis for fast reads, invalidated after accepted score updates. +- Add a fraud scoring layer for impossible action frequency or abnormal score growth. +- Add admin-only endpoints to disable a score rule or quarantine a suspicious user. +- Add integration tests covering idempotency, duplicate completions, invalid proof tokens, and concurrent updates. +- Add load tests for realtime fan-out if the scoreboard has many connected viewers. +- Start with PostgreSQL-only leaderboard reads. Add Redis sorted sets only when read traffic or realtime fan-out requires it.