From 01ec72056d28fecb83ec57e4b2018574b6fd6a1c Mon Sep 17 00:00:00 2001 From: Muhammad Zayyad Mukhtar Date: Thu, 26 Feb 2026 12:40:23 +0100 Subject: [PATCH] refactor(backend): consolidate api response utilities and improve route handlers - Rename `apiResponse.ts` to `response.ts` and update all imports - Add comprehensive backend architecture documentation - Refactor `/api/commitments` and `/api/attestations` route handlers to use consistent patterns - Simplify validation logic and improve error handling - Update README to reference new architecture documentation - Add Zod dependency for future validation improvements --- README.md | 3 +- docs/api-response-format.md | 8 +- docs/backend-architecture.md | 91 +++++ package-lock.json | 63 ++- src/app/api/attestations/route.ts | 363 +++++------------- src/app/api/auth/route.ts | 2 +- src/app/api/commitments/[id]/route.ts | 2 +- src/app/api/commitments/route.ts | 173 ++------- src/app/api/health/route.ts | 11 +- .../api/marketplace/listings/[id]/route.ts | 2 +- src/app/api/marketplace/listings/route.ts | 2 +- src/app/api/metrics/route.ts | 2 +- src/app/api/seed/route.ts | 2 +- src/lib/backend/index.ts | 4 +- .../backend/{apiResponse.ts => response.ts} | 0 src/lib/backend/withApiHandler.ts | 2 +- 16 files changed, 278 insertions(+), 452 deletions(-) create mode 100644 docs/backend-architecture.md rename src/lib/backend/{apiResponse.ts => response.ts} (100%) diff --git a/README.md b/README.md index 4c00923..380b932 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,8 @@ The application is built using the **Next.js App Router** architecture. - **Blockchain Interaction**: `@stellar/stellar-sdk` and `@stellar/freighter-api` (via `src/utils/soroban.ts`). - **Data Visualization**: `recharts` for health metrics and performance charts. -For a deep dive into the system design, modules, and data flow, please refer to [ARCHITECTURE.md](./ARCHITECTURE.md). +- For a deep dive into the system design, modules, and data flow, please refer to [ARCHITECTURE.md](./ARCHITECTURE.md). +- For backend implementation details and contribution guidelines, see [docs/backend-architecture.md](./docs/backend-architecture.md). ## ๐Ÿ”„ Backend API Changelog diff --git a/docs/api-response-format.md b/docs/api-response-format.md index 25d32ae..45659ba 100644 --- a/docs/api-response-format.md +++ b/docs/api-response-format.md @@ -66,7 +66,7 @@ All API routes in this project return a consistent JSON envelope so that the fro ### Returning a success response ```ts -import { ok } from '@/lib/backend/apiResponse'; +import { ok } from '@/lib/backend/response'; // Simple success return ok({ status: 'healthy' }); @@ -92,7 +92,7 @@ Throw a typed error inside any route wrapped with `withApiHandler` โ€” it will b ```ts import { withApiHandler } from '@/lib/backend/withApiHandler'; -import { ok } from '@/lib/backend/apiResponse'; +import { ok } from '@/lib/backend/response'; import { NotFoundError, ValidationError } from '@/lib/backend/errors'; export const GET = withApiHandler(async (req) => { @@ -123,7 +123,7 @@ Available error classes (all from `@/lib/backend/errors`): ```ts // src/app/api/health/route.ts import { withApiHandler } from '@/lib/backend/withApiHandler'; -import { ok } from '@/lib/backend/apiResponse'; +import { ok } from '@/lib/backend/response'; export const GET = withApiHandler(async () => { return ok({ status: 'healthy' }); @@ -137,7 +137,7 @@ export const GET = withApiHandler(async () => { | File | Purpose | |------|---------| -| `src/lib/backend/apiResponse.ts` | `ok()` and `fail()` response helpers | +| `src/lib/backend/response.ts` | `ok()` and `fail()` response helpers | | `src/lib/backend/errors.ts` | Typed error classes with HTTP status codes | | `src/lib/backend/withApiHandler.ts` | HOF that catches `ApiError` and calls `fail()` | | `docs/api-response-format.md` | This document | diff --git a/docs/backend-architecture.md b/docs/backend-architecture.md new file mode 100644 index 0000000..10b7620 --- /dev/null +++ b/docs/backend-architecture.md @@ -0,0 +1,91 @@ +# Backend Architecture & Guidelines + +This document outlines the architectural patterns and contribution guidelines for the CommitLabs backend, which is implemented as a set of **Next.js API Routes**. + +## ๐Ÿ— Directory Structure + +The backend code is organized into two main directories: + +1. **`src/app/api`**: Contains the route handlers (controllers). +2. **`src/lib/backend`**: Contains the core business logic, services, and utilities. + +``` +src/ +โ”œโ”€โ”€ app/ +โ”‚ โ””โ”€โ”€ api/ # Route Handlers (Controllers) +โ”‚ โ”œโ”€โ”€ commitments/ # /api/commitments +โ”‚ โ”œโ”€โ”€ attestations/ # /api/attestations +โ”‚ โ”œโ”€โ”€ marketplace/ # /api/marketplace +โ”‚ โ””โ”€โ”€ ... +โ””โ”€โ”€ lib/ + โ””โ”€โ”€ backend/ # Core Logic + โ”œโ”€โ”€ services/ # Domain Services (Business Logic) + โ”œโ”€โ”€ config.ts # Environment & Contract Config + โ”œโ”€โ”€ errors.ts # Standard Error Classes + โ”œโ”€โ”€ response.ts # JSON Response Helpers (ok, fail) + โ”œโ”€โ”€ withApiHandler.ts # HOF for Error Handling + โ””โ”€โ”€ ... +``` + +## ๐Ÿ“ Architectural Patterns + +### 1. Route Handlers (Controllers) +- **Location**: `src/app/api/**/route.ts` +- **Responsibility**: + - Parse request inputs (query params, body). + - Validate inputs (using Zod or manual checks). + - Call the appropriate **Service** method. + - Return a standardized JSON response using `ok()` or `fail()`. +- **Constraint**: Route handlers should contain **minimal logic**. They are strictly for I/O handling. +- **Pattern**: Always wrap handlers with `withApiHandler` to ensure consistent error handling. + +**Example:** +```typescript +import { withApiHandler } from '@/lib/backend/withApiHandler'; +import { ok } from '@/lib/backend/response'; +import { commitmentService } from '@/lib/backend/services/commitment'; + +export const GET = withApiHandler(async (req) => { + const data = await commitmentService.getAll(); + return ok(data); +}); +``` + +### 2. Services (Business Logic) +- **Location**: `src/lib/backend/services/*.ts` +- **Responsibility**: + - Contain the core business rules. + - Interact with the database (or mocks) and blockchain. + - Throw typed errors (e.g., `NotFoundError`, `ValidationError`) which are caught by the route handler. +- **Naming**: `[domain].ts` (e.g., `marketplace.ts`, `contracts.ts`). + +### 3. Standard Response Format +All API responses must follow the [Standard JSON Format](./api-response-format.md). +- Use `ok(data, status?)` for success. +- Use `fail(code, message, details?, status?)` for errors. + +### 4. Configuration +- **Location**: `src/lib/backend/config.ts` +- **Usage**: Use `getBackendConfig()` or `loadContractsConfig()` to access environment variables. **Do not use `process.env` directly** in application code. + +## ๐Ÿค Contribution Guidelines + +### Creating a New Endpoint +1. **Design the Route**: Choose a RESTful path (e.g., `/api/commitments/[id]/settle`). +2. **Create the Service Method**: Implement the logic in the relevant service file (e.g., `src/lib/backend/services/commitment.ts`). +3. **Create the Route Handler**: Create `src/app/api/.../route.ts` and call the service. +4. **Error Handling**: Throw specific errors from `src/lib/backend/errors.ts`. Do not return 400/500 responses manually unless necessary. + +### Modifying Shared Utilities +- Files like `withApiHandler.ts`, `response.ts`, and `errors.ts` are used globally. +- **Avoid changing them** unless you are fixing a critical bug or introducing a widely agreed-upon feature. +- If you add a new error code, update `docs/api-response-format.md`. + +### Naming Conventions +- **Routes**: Kebab-case folders (e.g., `early-exit`). +- **Services**: CamelCase instances, PascalCase classes. +- **DTOs**: PascalCase interfaces in `src/lib/types/domain.ts`. + +## ๐Ÿงช Testing +- Write unit tests for Services using Vitest. +- Mock external dependencies (blockchain calls) when testing business logic. diff --git a/package-lock.json b/package-lock.json index df6bd08..2d57e96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "react-icons": "^5.5.0", "recharts": "^3.7.0", "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.18" + "tailwindcss": "^4.1.18", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^20.19.33", @@ -49,6 +50,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, "optional": true, "dependencies": { "@emnapi/wasi-threads": "1.1.0", @@ -59,6 +61,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -68,6 +71,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -697,6 +701,7 @@ "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, "optional": true, "dependencies": { "@emnapi/core": "^1.4.3", @@ -1666,6 +1671,7 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -1762,7 +1768,6 @@ "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1778,7 +1783,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1840,7 +1844,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2426,7 +2429,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3544,7 +3546,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3707,7 +3708,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5901,7 +5901,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "peer": true, "engines": { "node": ">=12" }, @@ -6016,7 +6015,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6028,7 +6026,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6055,7 +6052,6 @@ "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -6103,8 +6099,7 @@ "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "peer": true + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -7169,7 +7164,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7285,7 +7279,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -7669,6 +7662,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -7681,6 +7683,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, "optional": true, "requires": { "@emnapi/wasi-threads": "1.1.0", @@ -7691,6 +7694,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, "optional": true, "requires": { "tslib": "^2.4.0" @@ -7700,6 +7704,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, "optional": true, "requires": { "tslib": "^2.4.0" @@ -8020,6 +8025,7 @@ "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, "optional": true, "requires": { "@emnapi/core": "^1.4.3", @@ -8582,6 +8588,7 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, "optional": true, "requires": { "tslib": "^2.4.0" @@ -8674,7 +8681,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "dev": true, - "peer": true, "requires": { "undici-types": "~6.21.0" } @@ -8690,7 +8696,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, - "peer": true, "requires": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -8737,7 +8742,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, - "peer": true, "requires": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -9077,8 +9081,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "peer": true + "dev": true }, "acorn-jsx": { "version": "5.3.2", @@ -9869,7 +9872,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "dev": true, - "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -9991,7 +9993,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, - "peer": true, "requires": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -11400,8 +11401,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "peer": true + "dev": true }, "possible-typed-array-names": { "version": "1.1.0", @@ -11472,7 +11472,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "requires": { "loose-envify": "^1.1.0" } @@ -11481,7 +11480,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "peer": true, "requires": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -11503,7 +11501,6 @@ "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "peer": true, "requires": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -11530,8 +11527,7 @@ "redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "peer": true + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, "redux-thunk": { "version": "3.1.0", @@ -12262,8 +12258,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "peer": true + "dev": true }, "unbox-primitive": { "version": "1.1.0", @@ -12357,7 +12352,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, - "peer": true, "requires": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12556,6 +12550,11 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==" } } } diff --git a/src/app/api/attestations/route.ts b/src/app/api/attestations/route.ts index ad89207..c4c8f19 100644 --- a/src/app/api/attestations/route.ts +++ b/src/app/api/attestations/route.ts @@ -1,22 +1,21 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { checkRateLimit } from '@/lib/backend/rateLimit'; -import { - getCommitmentFromChain, - recordAttestationOnChain, -} from '@/lib/backend/services/contracts'; -import { - normalizeBackendError, - toBackendErrorResponse, - ValidationError, -} from '@/lib/backend/errors'; -// src/app/api/attestations/route.ts import { NextRequest } from 'next/server'; import { checkRateLimit } from '@/lib/backend/rateLimit'; import { withApiHandler } from '@/lib/backend/withApiHandler'; -import { ok } from '@/lib/backend/apiResponse'; -import { TooManyRequestsError } from '@/lib/backend/errors'; -import { getMockData } from '@/lib/backend/mockDb'; -import type { RecordAttestationOnChainParams } from '@/lib/backend/services/contracts'; +import { ok } from '@/lib/backend/response'; +import { TooManyRequestsError, ValidationError } from '@/lib/backend/errors'; +import { logInfo } from '@/lib/backend/logger'; +import { + parsePaginationParams, + paginateArray, + PaginationParseError, + paginationErrorResponse +} from '@/lib/backend/pagination'; +import { + recordAttestationOnChain, + RecordAttestationOnChainParams +} from '@/lib/backend/services/contracts'; + +// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const ATTESTATION_TYPES = [ 'health_check', @@ -27,286 +26,122 @@ const ATTESTATION_TYPES = [ export type AttestationType = (typeof ATTESTATION_TYPES)[number]; -function isAttestationType(value: unknown): value is AttestationType { - return typeof value === 'string' && ATTESTATION_TYPES.includes(value as AttestationType); -} - -export interface RecordAttestationRequestBody { +interface RecordAttestationRequestBody { commitmentId: string; attestationType: AttestationType; data: Record; verifiedBy: string; } -function ensureObject(value: unknown, field: string): Record { - if (value === null || value === undefined) { - throw new ValidationError(`Missing required field: ${field}.`, { field }); - } - if (typeof value !== 'object' || Array.isArray(value)) { - throw new ValidationError(`Field "${field}" must be an object.`, { field }); - } - return value as Record; -} +// โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -function ensureNonEmptyString(value: unknown, field: string): string { - if (typeof value !== 'string' || value.trim() === '') { - throw new ValidationError(`Field "${field}" must be a non-empty string.`, { - field, - }); - } - return value.trim(); +function isAttestationType(value: unknown): value is AttestationType { + return typeof value === 'string' && ATTESTATION_TYPES.includes(value as AttestationType); } function parseAndValidateBody(raw: unknown): RecordAttestationRequestBody { - const body = raw !== null && typeof raw === 'object' ? (raw as Record) : null; - if (!body) { - throw new ValidationError('Request body must be a JSON object.'); - } - - const commitmentId = ensureNonEmptyString(body.commitmentId, 'commitmentId'); - const attestationType = body.attestationType; - if (!isAttestationType(attestationType)) { - throw new ValidationError( - `Invalid attestationType. Must be one of: ${ATTESTATION_TYPES.join(', ')}.`, - { field: 'attestationType', allowed: ATTESTATION_TYPES } - ); - } - const data = ensureObject(body.data, 'data'); - const verifiedBy = ensureNonEmptyString(body.verifiedBy, 'verifiedBy'); - - if (attestationType === 'health_check') { - const score = data.complianceScore; - if (score === undefined || score === null) { - throw new ValidationError( - 'data.complianceScore is required for attestationType "health_check".', - { field: 'data.complianceScore' } - ); + const body = raw as Record | null; + if (!body || typeof body !== 'object') { + throw new ValidationError('Request body must be a JSON object.'); } - const num = Number(score); - if (!Number.isFinite(num) || num < 0 || num > 100) { - throw new ValidationError( - 'data.complianceScore must be a number between 0 and 100.', - { field: 'data.complianceScore' } - ); - } - } - if (attestationType === 'fee_generation') { - const feeEarned = data.feeEarned ?? data.amount; - if (feeEarned === undefined || feeEarned === null) { - throw new ValidationError( - 'data.feeEarned or data.amount is required for attestationType "fee_generation".', - { field: 'data' } - ); - } - } + const { commitmentId, attestationType, data, verifiedBy } = body; - return { - commitmentId, - attestationType, - data, - verifiedBy, - }; -} + if (typeof commitmentId !== 'string' || !commitmentId) throw new ValidationError('commitmentId is required'); + if (!isAttestationType(attestationType)) throw new ValidationError('Invalid attestationType'); + if (!data || typeof data !== 'object') throw new ValidationError('data object is required'); + if (typeof verifiedBy !== 'string' || !verifiedBy) throw new ValidationError('verifiedBy is required'); -function mapToRecordParams( - body: RecordAttestationRequestBody -): RecordAttestationOnChainParams { - const { commitmentId, attestationType, data, verifiedBy } = body; - const timestamp = new Date().toISOString(); -import { logAttestation } from '@/lib/backend/logger'; -import { validatePagination, validateFilters, validateAddress, handleValidationError, createAttestationSchema } from '@/lib/backend/validation'; - -export async function GET(request: NextRequest) { - try { - const { searchParams } = new URL(request.url); - const page = searchParams.get('page'); - const limit = searchParams.get('limit'); - const commitmentId = searchParams.get('commitmentId'); - const attester = searchParams.get('attester'); - - // Validate pagination - const pagination = validatePagination(page, limit); - - return ok({ attestations }, 200); -}); - - let complianceScore = 0; - let violation = false; - let feeEarned: string | undefined; + return { + commitmentId, + attestationType, + data: data as Record, + verifiedBy + }; +} - if (attestationType === 'health_check') { - complianceScore = Number(data.complianceScore); - violation = Boolean(data.violation); - } else if (attestationType === 'violation') { - violation = true; - complianceScore = - typeof data.complianceScore === 'number' && Number.isFinite(data.complianceScore) - ? data.complianceScore - : 0; - } else if (attestationType === 'fee_generation') { - const raw = data.feeEarned ?? data.amount; - feeEarned = - typeof raw === 'string' ? raw : typeof raw === 'number' ? String(raw) : '0'; - complianceScore = - typeof data.complianceScore === 'number' && Number.isFinite(data.complianceScore) - ? data.complianceScore - : 0; - } else { - complianceScore = - typeof data.complianceScore === 'number' && Number.isFinite(data.complianceScore) - ? data.complianceScore - : 0; - violation = Boolean(data.violation); - const rawFee = data.feeEarned ?? data.amount; - if (rawFee !== undefined && rawFee !== null) { - feeEarned = - typeof rawFee === 'string' ? rawFee : typeof rawFee === 'number' ? String(rawFee) : undefined; +function mapToRecordParams(body: RecordAttestationRequestBody): RecordAttestationOnChainParams { + const { commitmentId, attestationType, data, verifiedBy } = body; + const timestamp = new Date().toISOString(); + + let complianceScore = 0; + let violation = false; + let feeEarned: string | undefined; + + // Simplified mapping logic based on previous code + if (attestationType === 'health_check') { + complianceScore = Number(data.complianceScore) || 0; + violation = Boolean(data.violation); + } else if (attestationType === 'violation') { + violation = true; + } else if (attestationType === 'fee_generation') { + feeEarned = String(data.feeEarned || data.amount || '0'); } - } - return { - commitmentId, - attestorAddress: verifiedBy, - complianceScore, - violation, - feeEarned, - timestamp, - details: { type: attestationType, ...data }, - }; + return { + commitmentId, + attestorAddress: verifiedBy, + complianceScore, + violation, + feeEarned, + timestamp, + details: { type: attestationType, ...data }, + }; } - try { - const body = (await req.json()) as Record; - logAttestation({ ip, ...body }); - } catch { - logAttestation({ ip, error: 'failed to parse request body' }); - } - // TODO(issue-126): Enforce validateSession(req) per docs/backend-session-csrf.md before mutating state. - // TODO(issue-126): Enforce CSRF validation for browser cookie-auth requests (token + origin checks). - // TODO: verify on-chain data, store attestation in database, etc. +// โ”€โ”€ GET Handler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export const GET = withApiHandler(async (req: NextRequest) => { - const ip = req.ip ?? req.headers.get('x-forwarded-for') ?? 'anonymous'; + const ip = req.ip ?? req.headers.get('x-forwarded-for') ?? 'anonymous'; + const isAllowed = await checkRateLimit(ip, 'api/attestations'); - const isAllowed = await checkRateLimit(ip, 'api/attestations'); - if (!isAllowed) { - throw new TooManyRequestsError(); - } + if (!isAllowed) { + throw new TooManyRequestsError(); + } - const { attestations } = await getMockData(); + const { searchParams } = new URL(req.url); - return ok({ attestations }, 200); + try { + const pagination = parsePaginationParams(searchParams, { defaultPageSize: 10 }); + + // Mock data + const attestations = [ + { id: '1', commitmentId: '123', attester: 'GABC...', rating: 5, comment: 'Great commitment!' }, + ]; + + return ok(paginateArray(attestations, pagination)); + } catch (err) { + if (err instanceof PaginationParseError) return paginationErrorResponse(err); + throw err; + } }); -export const POST = withApiHandler(async (req: NextRequest) => { - const ip = req.ip ?? req.headers.get('x-forwarded-for') ?? 'anonymous'; +// โ”€โ”€ POST Handler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - const isAllowed = await checkRateLimit(ip, 'api/attestations'); - if (!isAllowed) { - throw new TooManyRequestsError(); - } +export const POST = withApiHandler(async (req: NextRequest) => { + const ip = req.ip ?? req.headers.get('x-forwarded-for') ?? 'anonymous'; + const isAllowed = await checkRateLimit(ip, 'api/attestations'); - let body: RecordAttestationRequestBody; - try { - const raw = await req.json(); - body = parseAndValidateBody(raw); - } catch (err) { - if (err instanceof ValidationError) throw err; - throw new ValidationError('Invalid JSON in request body.'); - } + if (!isAllowed) { + throw new TooManyRequestsError(); + } - try { - await getCommitmentFromChain(body.commitmentId); - } catch (err) { - const normalized = normalizeBackendError(err, { - code: 'BLOCKCHAIN_CALL_FAILED', - message: 'Invalid commitment or unable to fetch commitment from chain.', - status: 502, - details: { commitmentId: body.commitmentId }, - }); - return NextResponse.json(toBackendErrorResponse(normalized), { - status: normalized.status, - }); - } + let body: RecordAttestationRequestBody; + try { + const raw = await req.json(); + body = parseAndValidateBody(raw); + } catch (err) { + if (err instanceof ValidationError) throw err; + throw new ValidationError('Invalid JSON in request body.'); + } - const params = mapToRecordParams(body); + logInfo(req, 'Recording attestation', { commitmentId: body.commitmentId }); - try { + const params = mapToRecordParams(body); const result = await recordAttestationOnChain(params); - const summary = { - attestationId: result.attestationId, - commitmentId: result.commitmentId, - complianceScore: result.complianceScore, - violation: result.violation, - feeEarned: result.feeEarned, - recordedAt: result.recordedAt, - }; - - return ok( - { - attestation: summary, + return ok({ + attestation: result, txReference: result.txHash ?? null, - }, - 201 - ); - } catch (err) { - const normalized = normalizeBackendError(err, { - code: 'BLOCKCHAIN_CALL_FAILED', - message: 'Failed to record attestation on chain.', - status: 502, - details: { commitmentId: body.commitmentId, attestationType: body.attestationType }, - }); - return NextResponse.json(toBackendErrorResponse(normalized), { - status: normalized.status, - }); - } + }, 201); }); - // Validate filters - const filters = validateFilters({ commitmentId, attester }); - - // Validate addresses if provided - if (filters.attester) { - validateAddress(filters.attester as string); - } - - // Mock response - const attestations = [ - { id: '1', commitmentId: '123', attester: 'GABC...', rating: 5, comment: 'Great commitment!' }, - // ... more - ]; - - return Response.json({ - attestations, - pagination, - filters, - total: attestations.length - }); - } catch (error) { - return handleValidationError(error); - } -} - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - - // Validate request body - const validatedData = createAttestationSchema.parse(body); - - // Mock creation - const newAttestation = { - id: Date.now().toString(), - commitmentId: validatedData.commitmentId, - attester: validatedData.attesterAddress, - rating: validatedData.rating, - comment: validatedData.comment || '', - createdAt: new Date().toISOString() - }; - - return Response.json(newAttestation, { status: 201 }); - } catch (error) { - return handleValidationError(error); - } -} diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts index 0d9e3ed..69fb2f5 100644 --- a/src/app/api/auth/route.ts +++ b/src/app/api/auth/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from 'next/server'; import { checkRateLimit } from '@/lib/backend/rateLimit'; import { withApiHandler } from '@/lib/backend/withApiHandler'; -import { ok } from '@/lib/backend/apiResponse'; +import { ok } from '@/lib/backend/response'; import { TooManyRequestsError } from '@/lib/backend/errors'; export const POST = withApiHandler(async (req: NextRequest) => { diff --git a/src/app/api/commitments/[id]/route.ts b/src/app/api/commitments/[id]/route.ts index 7a58e59..ab4c037 100644 --- a/src/app/api/commitments/[id]/route.ts +++ b/src/app/api/commitments/[id]/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from 'next/server'; -import { ok } from '@/lib/backend/apiResponse'; +import { ok } from '@/lib/backend/response'; import { NotFoundError } from '@/lib/backend/errors'; import { withApiHandler } from '@/lib/backend/withApiHandler'; import { contractAddresses } from '@/utils/soroban'; diff --git a/src/app/api/commitments/route.ts b/src/app/api/commitments/route.ts index 0543e98..9edbd97 100644 --- a/src/app/api/commitments/route.ts +++ b/src/app/api/commitments/route.ts @@ -1,58 +1,19 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { checkRateLimit } from '@/lib/backend/rateLimit'; -import { createCommitmentOnChain } from '@/lib/backend/services/contracts'; -import { - normalizeBackendError, - toBackendErrorResponse -} from '@/lib/backend/errors'; - -// src/app/api/commitments/route.ts import { NextRequest } from 'next/server'; import { checkRateLimit } from '@/lib/backend/rateLimit'; import { withApiHandler } from '@/lib/backend/withApiHandler'; -import { ok } from '@/lib/backend/apiResponse'; +import { ok } from '@/lib/backend/response'; import { TooManyRequestsError } from '@/lib/backend/errors'; -import { getMockData } from '@/lib/backend/mockDb'; -import type { Commitment, CommitmentType, CommitmentStatus } from '@/lib/types/domain'; -import { logInfo } from '@/lib/backend/logger'; -import { validatePagination, validateFilters, validateAddress, handleValidationError, ValidationError, createCommitmentSchema } from '@/lib/backend/validation'; - -export async function GET(request: NextRequest) { - try { - const { searchParams } = new URL(request.url); - const page = searchParams.get('page'); - const limit = searchParams.get('limit'); - const status = searchParams.get('status'); - const creator = searchParams.get('creator'); - - // Validate pagination - const pagination = validatePagination(page, limit); - - const isAllowed = await checkRateLimit(ip, 'api/commitments'); - if (!isAllowed) { - throw new TooManyRequestsError(); - } - - const { commitments } = await getMockData(); - - return ok({ commitments }, 200); -}); +import { createCommitmentOnChain } from '@/lib/backend/services/contracts'; import { logInfo } from '@/lib/backend/logger'; -import { getBackendConfig } from '@/lib/backend/config'; -import { createCommitmentOnChain } from '@/lib/backend/contracts'; -import { parseCreateCommitmentInput } from '@/lib/backend/validation'; -import { mapCommitmentFromChain } from '@/lib/backend/dto'; import { parsePaginationParams, parseSortParams, parseEnumFilter, paginateArray, - paginationErrorResponse, PaginationParseError, + paginationErrorResponse } from '@/lib/backend/pagination'; - -// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -// Commitment, CommitmentType, CommitmentStatus from @/lib/types/domain +import type { Commitment, CommitmentType, CommitmentStatus } from '@/lib/types/domain'; // โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -74,6 +35,8 @@ const MOCK_COMMITMENTS: Commitment[] = [ { id: 'CMT-STU234', type: 'Balanced', status: 'Active', asset: 'USDC', amount: '80000', complianceScore: 85, daysRemaining: 33, createdAt: '2026-01-05T00:00:00Z' }, ]; +// โ”€โ”€ GET Handler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + /** * GET /api/commitments * @@ -87,15 +50,12 @@ const MOCK_COMMITMENTS: Commitment[] = [ * Example: * /api/commitments?type=Safe&status=Active&sortBy=amount&sortOrder=desc&page=1&pageSize=5 */ -export async function GET(req: NextRequest): Promise { +export const GET = withApiHandler(async (req: NextRequest) => { const ip = req.ip ?? req.headers.get('x-forwarded-for') ?? 'anonymous'; const isAllowed = await checkRateLimit(ip, 'api/commitments'); if (!isAllowed) { - return NextResponse.json( - { error: 'Too many requests' }, - { status: 429 } - ); + throw new TooManyRequestsError(); } const { searchParams } = new URL(req.url); @@ -111,8 +71,17 @@ export async function GET(req: NextRequest): Promise { if (statusFilter) results = results.filter((c) => c.status === statusFilter); results = [...results].sort((a, b) => { - const valA = a[sortBy]; - const valB = b[sortBy]; + // Helper to get value or default for optional fields + const getVal = (obj: Commitment, key: CommitmentSortField) => { + if (key === 'createdAt') return obj.createdAt || ''; + if (key === 'complianceScore') return obj.complianceScore || 0; + if (key === 'daysRemaining') return obj.daysRemaining || 0; + return obj[key]; + }; + + const valA = getVal(a, sortBy); + const valB = getVal(b, sortBy); + if (typeof valA === 'string' && typeof valB === 'string') { return sortOrder === 'asc' ? valA.localeCompare(valB) : valB.localeCompare(valA); } @@ -121,18 +90,16 @@ export async function GET(req: NextRequest): Promise { return sortOrder === 'asc' ? numA - numB : numB - numA; }); - return NextResponse.json({ success: true, data: paginateArray(results, pagination) }); + return ok(paginateArray(results, pagination)); } catch (err) { if (err instanceof PaginationParseError) return paginationErrorResponse(err); - console.error('[GET /api/commitments] Unhandled error:', err); - return NextResponse.json( - { success: false, error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred.' } }, - { status: 500 } - ); + throw err; } -} +}); + +// โ”€โ”€ POST Handler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ interface CreateCommitmentRequestBody { ownerAddress: string; @@ -151,81 +118,19 @@ export const POST = withApiHandler(async (req: NextRequest) => { throw new TooManyRequestsError(); } - try { - const body = (await req.json()) as CreateCommitmentRequestBody; - const result = await createCommitmentOnChain({ - ownerAddress: body.ownerAddress, - asset: body.asset, - amount: body.amount, - durationDays: body.durationDays, - maxLossBps: body.maxLossBps, - metadata: body.metadata - }); - return NextResponse.json(result, { status: 201 }); - } catch (error) { - const normalized = normalizeBackendError(error, { - code: 'INTERNAL_ERROR', - message: 'Failed to create commitment.', - status: 500 - }); - return NextResponse.json(toBackendErrorResponse(normalized), { - status: normalized.status - }); - } -}); - logInfo(req, 'Creating commitment', { ip }); - - // TODO(issue-126): Enforce validateSession(req) per docs/backend-session-csrf.md before mutating state. - // TODO(issue-126): Enforce CSRF validation for browser cookie-auth requests (token + origin checks). - // TODO: validate request body, interact with Soroban smart contract, - // store commitment record in database, mint NFT, etc. - - return ok({ message: 'Commitment created successfully.' }, 201); -}); - // Validate filters - const filters = validateFilters({ status, creator }); - - // If creator is provided, validate it as address - if (filters.creator) { - validateAddress(filters.creator as string); - } - - // Mock response - in real app, fetch from database - const commitments = [ - { id: '1', title: 'Sample Commitment', creator: 'GABC...', amount: 100 }, - // ... more - ]; - - return Response.json({ - commitments, - pagination, - filters, - total: commitments.length + const body = (await req.json()) as CreateCommitmentRequestBody; + + logInfo(req, 'Creating commitment', { ip, owner: body.ownerAddress }); + + // Mock implementation or calls to services + const result = await createCommitmentOnChain({ + ownerAddress: body.ownerAddress, + asset: body.asset, + amount: body.amount, + durationDays: body.durationDays, + maxLossBps: body.maxLossBps, + metadata: body.metadata }); - } catch (error) { - return handleValidationError(error); - } -} - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - - // Validate request body - const validatedData = createCommitmentSchema.parse(body); - - // Mock creation - in real app, save to database - const newCommitment = { - id: Date.now().toString(), - title: validatedData.title, - description: validatedData.description, - amount: validatedData.amount, - creator: validatedData.creatorAddress, - createdAt: new Date().toISOString() - }; - - return Response.json(newCommitment, { status: 201 }); - } catch (error) { - return handleValidationError(error); - } -} + + return ok(result, 201); +}); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 800bdac..d79fc38 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,14 +1,9 @@ +import { NextRequest } from 'next/server'; import { withApiHandler } from '@/lib/backend/withApiHandler'; -import { ok } from '@/lib/backend/apiResponse'; +import { ok } from '@/lib/backend/response'; import { logInfo } from '@/lib/backend/logger'; -import { NextRequest } from 'next/server'; -export async function GET(req: NextRequest) { - logInfo(req, 'Healthcheck requested'); - const response = NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() }); - return attachSecurityHeaders(response); -} export const GET = withApiHandler(async (req: NextRequest) => { logInfo(req, 'Health check requested'); - return ok({ status: 'healthy' }); + return ok({ status: 'healthy', timestamp: new Date().toISOString() }); }); diff --git a/src/app/api/marketplace/listings/[id]/route.ts b/src/app/api/marketplace/listings/[id]/route.ts index 66c7483..034eaab 100644 --- a/src/app/api/marketplace/listings/[id]/route.ts +++ b/src/app/api/marketplace/listings/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from 'next/server'; import { withApiHandler } from '@/lib/backend/withApiHandler'; -import { ok } from '@/lib/backend/apiResponse'; +import { ok } from '@/lib/backend/response'; import { ValidationError } from '@/lib/backend/errors'; import { marketplaceService } from '@/lib/backend/services/marketplace'; import type { CancelListingResponse } from '@/types/marketplace'; diff --git a/src/app/api/marketplace/listings/route.ts b/src/app/api/marketplace/listings/route.ts index 00b5e49..8e5d2ec 100644 --- a/src/app/api/marketplace/listings/route.ts +++ b/src/app/api/marketplace/listings/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { ok } from '@/lib/backend/apiResponse'; +import { ok } from '@/lib/backend/response'; import { checkRateLimit } from '@/lib/backend/rateLimit'; import { withApiHandler } from '@/lib/backend/withApiHandler'; import { ValidationError } from '@/lib/backend/errors'; diff --git a/src/app/api/metrics/route.ts b/src/app/api/metrics/route.ts index bc2c1b8..5bd5a02 100644 --- a/src/app/api/metrics/route.ts +++ b/src/app/api/metrics/route.ts @@ -1,5 +1,5 @@ import { withApiHandler } from '@/lib/backend/withApiHandler'; -import { ok } from '@/lib/backend/apiResponse'; +import { ok } from '@/lib/backend/response'; import type { HealthMetrics } from '@/lib/types/domain'; export const GET = withApiHandler(async () => { diff --git a/src/app/api/seed/route.ts b/src/app/api/seed/route.ts index 0b040d0..27255c2 100644 --- a/src/app/api/seed/route.ts +++ b/src/app/api/seed/route.ts @@ -1,5 +1,5 @@ import { withApiHandler } from '@/lib/backend/withApiHandler'; -import { ok } from '@/lib/backend/apiResponse'; +import { ok } from '@/lib/backend/response'; import { exec } from 'child_process'; import { promisify } from 'util'; diff --git a/src/lib/backend/index.ts b/src/lib/backend/index.ts index bb17139..f18fa21 100644 --- a/src/lib/backend/index.ts +++ b/src/lib/backend/index.ts @@ -1,6 +1,6 @@ export { logger } from './logger'; -export { ok, fail } from './apiResponse'; -export type { OkResponse, FailResponse, ApiResponse } from './apiResponse'; +export { ok, fail } from './response'; +export type { OkResponse, FailResponse, ApiResponse } from './response'; export { getBackendConfig } from './config'; export { createCommitmentOnChain, diff --git a/src/lib/backend/apiResponse.ts b/src/lib/backend/response.ts similarity index 100% rename from src/lib/backend/apiResponse.ts rename to src/lib/backend/response.ts diff --git a/src/lib/backend/withApiHandler.ts b/src/lib/backend/withApiHandler.ts index 909cc9b..ca4403f 100644 --- a/src/lib/backend/withApiHandler.ts +++ b/src/lib/backend/withApiHandler.ts @@ -20,7 +20,7 @@ type RouteHandler = ( * ```ts * // app/api/commitments/route.ts * import { withApiHandler } from '@/lib/backend/withApiHandler'; - * import { ok } from '@/lib/backend/apiResponse'; + * import { ok } from '@/lib/backend/response'; * * export const GET = withApiHandler(async (req) => { * const commitments = await getCommitments();