From 713d2e650c5144cf4f6b19cd29c806e9c8ef3f56 Mon Sep 17 00:00:00 2001 From: datagirl Date: Fri, 28 Aug 2026 18:20:53 +0100 Subject: [PATCH 1/6] remove stale merge conflict markers --- .github/workflows/backend.yml | 43 +++++++++ apps/backend/document/openapi-spec.md | 60 ++++++++++++ apps/backend/package.json | 4 +- apps/backend/scripts/generate-openapi-spec.ts | 32 +++++++ .../src/analytics/analytics.controller.ts | 5 + .../src/analytics/dto/chart-data.dto.ts | 16 +++- apps/backend/src/app.controller.ts | 12 ++- apps/backend/src/audit/audit.controller.ts | 4 + apps/backend/src/auth/auth.controller.ts | 28 ++++++ apps/backend/src/auth/auth.service.ts | 2 + apps/backend/src/bootstrap/swagger.config.ts | 96 +++++++++++++++++++ .../decorators/api-idempotency.decorator.ts | 27 ++++++ .../src/crowdfund/crowdfund.controller.ts | 27 +++++- apps/backend/src/export/dto/export-job.dto.ts | 33 +++++-- apps/backend/src/export/export.controller.ts | 48 ++++++++-- .../feature-flags/feature-flags.controller.ts | 11 ++- apps/backend/src/grants/grants.controller.ts | 19 ++++ apps/backend/src/health/health.controller.ts | 6 +- apps/backend/src/main.ts | 45 +-------- .../backend/src/metrics/metrics.controller.ts | 12 ++- .../model-retraining.controller.ts | 2 + .../src/moderation/moderation.controller.ts | 14 +++ apps/backend/src/news/news.controller.ts | 14 +++ .../notification-preference.controller.ts | 14 ++- .../src/portfolio/portfolio.controller.ts | 9 ++ .../entities/reconciliation-job.entity.ts | 41 +++++++- .../reconciliation.controller.ts | 8 ++ apps/backend/src/search/search.controller.ts | 32 +++++++ .../backend/src/signals/signals.controller.ts | 6 ++ .../soroban-events.controller.ts | 2 + .../matching-pool-admin.controller.ts | 10 +- .../src/stellar/dto/matching-pool.dto.ts | 7 ++ .../services/soroban-rpc-client.service.ts | 8 +- .../backend/src/stellar/stellar.controller.ts | 61 +++++++++++- .../telegram-bot/telegram-bot.controller.ts | 6 ++ apps/backend/src/test-exception.controller.ts | 3 + apps/backend/src/test/test.controller.ts | 4 + .../src/treasury/treasury.controller.ts | 2 + .../src/users/dto/profile-response.dto.ts | 24 ++++- .../src/users/dto/user-admin-response.dto.ts | 68 +++++++++++++ apps/backend/src/users/users.controller.ts | 74 ++++++++++++-- .../verification/verification.controller.ts | 87 ++++++++++++++++- .../vesting-wallet.controller.ts | 3 + .../src/watchlist/watchlist.controller.ts | 6 ++ .../src/webhook/webhook-admin.controller.ts | 20 +++- .../backend/src/webhook/webhook.controller.ts | 2 + apps/backend/tsconfig.json | 1 + document/backend-contributing.md | 6 +- 48 files changed, 973 insertions(+), 91 deletions(-) create mode 100644 apps/backend/document/openapi-spec.md create mode 100644 apps/backend/scripts/generate-openapi-spec.ts create mode 100644 apps/backend/src/bootstrap/swagger.config.ts create mode 100644 apps/backend/src/common/decorators/api-idempotency.decorator.ts create mode 100644 apps/backend/src/users/dto/user-admin-response.dto.ts diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 06973e6a6..8b392dac0 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -19,6 +19,43 @@ jobs: run: working-directory: ./apps/backend + # Generating the OpenAPI spec boots the full Nest DI graph (TypeORM + + # Redis-backed cache/rate-limit modules), so the freshness check below + # needs real service containers, mirroring the root docker-compose.yml. + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: lumenpulse + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + NODE_ENV: test + DB_HOST: localhost + DB_PORT: '5432' + DB_USERNAME: postgres + DB_PASSWORD: postgres + DB_DATABASE: lumenpulse + JWT_SECRET: ci-openapi-spec-secret + STELLAR_SERVER_SECRET: SB6RIPM3GJQ7RP3Q6R5F3QIBYZHP4N27SGGCQ3R4LWA2ZKXZWQ3NU3G4 + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -49,6 +86,12 @@ jobs: - name: Build run: npm run build + - name: Run database migrations + run: npm run migration:run + + - name: Check committed OpenAPI spec is up to date + run: npm run openapi:check + migration-safety: runs-on: ubuntu-latest defaults: diff --git a/apps/backend/document/openapi-spec.md b/apps/backend/document/openapi-spec.md new file mode 100644 index 000000000..6ae105657 --- /dev/null +++ b/apps/backend/document/openapi-spec.md @@ -0,0 +1,60 @@ +# Committed OpenAPI Specification + +## Artifact path + +The full OpenAPI 3 document for the backend is committed at: + +``` +apps/backend/openapi/openapi.json +``` + +Any tool that needs a static description of the API — most notably the +webapp's client/type generation script — should read the spec from this +path rather than fetching it from a running server. This keeps client +generation reproducible in CI and in local builds that don't have a backend +process running. + +## Regenerating the spec + +The spec is produced from the same `DocumentBuilder` config the running +server uses to serve `/api/docs` (see `src/bootstrap/swagger.config.ts`), so +it always matches what `SwaggerModule` would emit at runtime. + +```bash +cd apps/backend +npm run openapi:generate +``` + +This boots the full Nest application (without calling `app.listen`), builds +the document via `SwaggerModule.createDocument`, and writes it to +`openapi/openapi.json`. Because it boots the real DI graph, it needs a +reachable Postgres and Redis — see the root `docker-compose.yml` for the +expected local services, or rely on the CI service containers described +below. + +Whenever a controller, DTO, or the Swagger config changes, regenerate and +commit the updated `openapi/openapi.json` alongside the code change. + +## CI freshness check + +`.github/workflows/backend.yml` runs `npm run openapi:check` after the build +step. That script regenerates the spec and then runs +`git diff --exit-code -- openapi/openapi.json`, so CI fails whenever the +committed artifact is stale relative to the code that produced it. The job +provisions ephemeral Postgres and Redis service containers (matching the +credentials in `test/setup-env.ts`) so the app can boot far enough to build +the document. + +## Authentication schemes described in the spec + +| Scheme | Type | Used by | +|---|---|---| +| `JWT-auth` | HTTP bearer (`Authorization: Bearer `) | Most authenticated user/admin endpoints (`@ApiBearerAuth('JWT-auth')`) | +| `soroban-ingest-secret` | API key header `x-ingest-secret` | Soroban event ingestion (`POST /soroban-events/ingest`) | +| `webhook-signature` | API key header `x-webhook-signature` | Inbound webhook delivery verification | + +Additionally, every mutating endpoint (`POST`/`PUT`/`PATCH`/`DELETE`) +documents the optional `Idempotency-Key` request header handled globally by +`IdempotencyInterceptor`, along with the `409` (duplicate request in +flight) and `422` (key reused with a different body) responses it can +produce. See `src/common/decorators/api-idempotency.decorator.ts`. diff --git a/apps/backend/package.json b/apps/backend/package.json index c361f4bcc..09b065a1a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -24,7 +24,9 @@ "migration:run": "npm run typeorm migration:run -- -d src/database/data-source.ts", "migration:revert": "npm run typeorm migration:revert -- -d src/database/data-source.ts", "migration:check": "ts-node scripts/check-migrations.ts", - "migration:verify": "ts-node scripts/verify-migrations-schema.ts" + "migration:verify": "ts-node scripts/verify-migrations-schema.ts", + "openapi:generate": "ts-node -r tsconfig-paths/register -r ./test/setup-env.ts scripts/generate-openapi-spec.ts", + "openapi:check": "npm run openapi:generate && git diff --exit-code -- openapi/openapi.json" }, "dependencies": { "@aws-sdk/client-s3": "^3.1019.0", diff --git a/apps/backend/scripts/generate-openapi-spec.ts b/apps/backend/scripts/generate-openapi-spec.ts new file mode 100644 index 000000000..a09b441b4 --- /dev/null +++ b/apps/backend/scripts/generate-openapi-spec.ts @@ -0,0 +1,32 @@ +import '../src/lib/config'; +import { NestFactory } from '@nestjs/core'; +import { SwaggerModule } from '@nestjs/swagger'; +import * as fs from 'fs'; +import * as path from 'path'; +import { AppModule } from '../src/app.module'; +import { buildSwaggerConfig } from '../src/bootstrap/swagger.config'; + +const OUTPUT_PATH = path.resolve(__dirname, '../openapi/openapi.json'); + +async function generate(): Promise { + // abortOnError: false so a bootstrap failure rejects the promise below + // (and is reported by this script) instead of Nest calling process.exit() + // internally before our own error handling runs. + const app = await NestFactory.create(AppModule, { + logger: ['error', 'warn'], + abortOnError: false, + }); + const document = SwaggerModule.createDocument(app, buildSwaggerConfig()); + + fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true }); + fs.writeFileSync(OUTPUT_PATH, `${JSON.stringify(document, null, 2)}\n`); + + await app.close(); + + console.log(`OpenAPI spec written to ${OUTPUT_PATH}`); +} + +generate().catch((error) => { + console.error('Failed to generate OpenAPI spec:', error); + process.exitCode = 1; +}); diff --git a/apps/backend/src/analytics/analytics.controller.ts b/apps/backend/src/analytics/analytics.controller.ts index c3b126b87..dfe029bd1 100644 --- a/apps/backend/src/analytics/analytics.controller.ts +++ b/apps/backend/src/analytics/analytics.controller.ts @@ -25,6 +25,11 @@ export class AnalyticsController { type: ChartDataPointDto, isArray: true, }) + @ApiResponse({ + status: 400, + description: 'Invalid query parameters (interval, range, or asset)', + }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async getChartData( @Query() query: ChartDataQueryDto, ): Promise { diff --git a/apps/backend/src/analytics/dto/chart-data.dto.ts b/apps/backend/src/analytics/dto/chart-data.dto.ts index fa9e39e24..31fee892f 100644 --- a/apps/backend/src/analytics/dto/chart-data.dto.ts +++ b/apps/backend/src/analytics/dto/chart-data.dto.ts @@ -1,5 +1,5 @@ import { IsEnum, IsOptional, IsString } from 'class-validator'; -import { ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export enum ChartInterval { ONE_HOUR = '1h', @@ -39,7 +39,21 @@ export class ChartDataQueryDto { } export class ChartDataPointDto { + @ApiProperty({ + description: 'Start of the bucket, in ISO-8601 format', + example: '2026-08-27T00:00:00.000Z', + }) timestamp: string; + + @ApiProperty({ + description: 'Average sentiment score for the bucket', + example: 0.42, + }) sentiment: number; + + @ApiProperty({ + description: 'Number of data points aggregated into the bucket', + example: 128, + }) count: number; } diff --git a/apps/backend/src/app.controller.ts b/apps/backend/src/app.controller.ts index 9f82d11f2..92ae57d4d 100644 --- a/apps/backend/src/app.controller.ts +++ b/apps/backend/src/app.controller.ts @@ -8,8 +8,16 @@ export class AppController { constructor(private readonly appService: AppService) {} @Get() - @ApiOperation({ summary: 'Root endpoint' }) - @ApiResponse({ status: 200, description: 'Returns Hello World' }) + @ApiOperation({ + summary: 'Root endpoint', + description: + 'Basic liveness/welcome endpoint. Returns a static greeting string; not used for health checks (see /health).', + }) + @ApiResponse({ + status: 200, + description: 'Returns Hello World', + schema: { type: 'string', example: 'Hello World!' }, + }) getHello(): string { return this.appService.getHello(); } diff --git a/apps/backend/src/audit/audit.controller.ts b/apps/backend/src/audit/audit.controller.ts index f8ea49e74..a757b20d8 100644 --- a/apps/backend/src/audit/audit.controller.ts +++ b/apps/backend/src/audit/audit.controller.ts @@ -57,6 +57,10 @@ export class AuditController { }, }, }) + @ApiResponse({ + status: 400, + description: 'Invalid limit or offset (must be numeric)', + }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) async getAuditLogs( diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index a72603ee5..632966bea 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -49,6 +49,7 @@ import { ActiveSessionsResponseDto, RevokeSessionResponseDto, } from './dto/session.dto'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; @ApiTags('auth') @Controller('auth') @@ -62,7 +63,9 @@ export class AuthController { @Post('login') @Throttle(getAuthThrottleOverride()) @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Login with email and password' }) + @ApiResponse({ status: 429, description: 'Too many login attempts' }) @ApiResponse({ status: 200, description: 'Login successful', @@ -110,6 +113,7 @@ export class AuthController { @Post('register') @Throttle(getAuthThrottleOverride()) @HttpCode(HttpStatus.CREATED) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Register a new user account' }) @ApiResponse({ status: 201, @@ -123,6 +127,7 @@ export class AuthController { }, }) @ApiResponse({ status: 400, description: 'Email already exists' }) + @ApiResponse({ status: 429, description: 'Too many registration attempts' }) async register(@Body() body: RegisterDto) { const existingUser = await this.usersService.findByEmail(body.email); if (existingUser) { @@ -144,6 +149,7 @@ export class AuthController { @Post('forgot-password') @Throttle(getAuthThrottleOverride()) @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Request a password reset token' }) @ApiResponse({ status: 200, @@ -154,6 +160,7 @@ export class AuthController { }, }, }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async forgotPassword(@Body() body: ForgotPasswordDto) { return this.authService.forgotPassword(body.email); } @@ -161,6 +168,7 @@ export class AuthController { @Post('reset-password') @Throttle(getAuthThrottleOverride()) @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Reset password using a one-time token' }) @ApiResponse({ status: 200, @@ -175,6 +183,7 @@ export class AuthController { status: 400, description: 'Invalid, expired, or already-used token', }) + @ApiResponse({ status: 429, description: 'Too many requests' }) @AuditLogAction('password_change') async resetPassword(@Body() body: ResetPasswordDto) { return this.authService.resetPassword(body.token, body.newPassword); @@ -183,6 +192,7 @@ export class AuthController { @Post('refresh') @Throttle(getAuthThrottleOverride()) @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Refresh access token using refresh token' }) @ApiResponse({ status: 200, @@ -198,6 +208,7 @@ export class AuthController { status: 401, description: 'Invalid or expired refresh token', }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async refreshToken( @Body() body: RefreshTokenDto, @Request() req: ExpressRequest, @@ -212,6 +223,7 @@ export class AuthController { @Post('logout') @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Logout user and invalidate refresh token' }) @ApiResponse({ status: 200, @@ -229,11 +241,14 @@ export class AuthController { @UseGuards(JwtAuthGuard) @Post('logout-all') @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() + @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Logout from all devices' }) @ApiResponse({ status: 200, description: 'Logout from all devices successful', }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) async logoutAll(@Request() req: { user: { sub: string } }) { return this.authService.logoutAll(req.user.sub); } @@ -313,6 +328,7 @@ export class AuthController { @Post('verify') @Throttle(getAuthThrottleOverride()) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Verify signed challenge and issue JWT' }) @ApiResponse({ status: 200, @@ -329,6 +345,7 @@ export class AuthController { status: 401, description: 'Invalid signature or expired challenge', }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async verifyChallenge(@Body() verifyChallengeDto: VerifyChallengeDto) { try { this.logger.log( @@ -361,12 +378,14 @@ export class AuthController { @UseGuards(JwtAuthGuard) @Get('sessions') + @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get active sessions for current user' }) @ApiResponse({ status: 200, description: 'Active sessions retrieved successfully', type: ActiveSessionsResponseDto, }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) async getActiveSessions(@Request() req: { user: { id: string } }) { const sessions = await this.authService.getActiveSessions(req.user.id); return sessions; @@ -375,12 +394,15 @@ export class AuthController { @UseGuards(JwtAuthGuard) @Post('sessions/:id/revoke') @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() + @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Revoke a specific session' }) @ApiResponse({ status: 200, description: 'Session revoked successfully', type: RevokeSessionResponseDto, }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 404, description: 'Session not found' }) async revokeSession( @Request() req: { user: { id: string } }, @@ -392,6 +414,7 @@ export class AuthController { @UseGuards(JwtAuthGuard) @Post('2fa/generate') @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Generate 2FA secret and QR code' }) @ApiResponse({ status: 200, @@ -404,6 +427,7 @@ export class AuthController { }, }) @ApiResponse({ status: 400, description: '2FA already enabled' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiBearerAuth('JWT-auth') async generateTwoFactorSecret(@Request() req: { user: { id: string } }) { return this.authService.generateTwoFactorSecret(req.user.id); @@ -412,6 +436,7 @@ export class AuthController { @UseGuards(JwtAuthGuard) @Post('2fa/enable') @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Enable 2FA with TOTP token' }) @ApiResponse({ status: 200, @@ -434,6 +459,7 @@ export class AuthController { @Post('2fa/verify') @Throttle(getAuthThrottleOverride()) @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Verify 2FA token during login' }) @ApiResponse({ status: 200, @@ -449,6 +475,7 @@ export class AuthController { status: 401, description: 'Invalid credentials or TOTP token', }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async verifyTwoFactor(@Body() body: TwoFactorVerifyDto) { const user = await this.authService.validateUser(body.email, body.password); if (!user) { @@ -474,6 +501,7 @@ export class AuthController { @UseGuards(JwtAuthGuard) @Post('2fa/disable') @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Disable 2FA' }) @ApiResponse({ status: 200, diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index 13d3a356c..141077323 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -1,5 +1,7 @@ import { Injectable, + Inject, + forwardRef, Logger, UnauthorizedException, BadRequestException, diff --git a/apps/backend/src/bootstrap/swagger.config.ts b/apps/backend/src/bootstrap/swagger.config.ts new file mode 100644 index 000000000..999838e53 --- /dev/null +++ b/apps/backend/src/bootstrap/swagger.config.ts @@ -0,0 +1,96 @@ +import { DocumentBuilder, OpenAPIObject } from '@nestjs/swagger'; + +/** + * Single source of truth for the OpenAPI document metadata. Shared by + * src/main.ts (serves /api/docs at runtime) and + * scripts/generate-openapi-spec.ts (writes the committed artifact used for + * the CI freshness check and by the webapp's client generation script — see + * document/openapi-spec.md) so both always describe the exact same API. + */ +export function buildSwaggerConfig(): Omit { + return new DocumentBuilder() + .setTitle('LumenPulse API') + .setDescription( + 'Comprehensive API documentation for LumenPulse - A decentralized crypto news aggregator and portfolio management platform built on Stellar blockchain', + ) + .setVersion('1.0') + .addBearerAuth( + { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Enter JWT token', + }, + 'JWT-auth', + ) + .addApiKey( + { + type: 'apiKey', + in: 'header', + name: 'x-ingest-secret', + description: + 'Shared secret used by the Soroban indexer/cron to authenticate event ingestion.', + }, + 'soroban-ingest-secret', + ) + .addApiKey( + { + type: 'apiKey', + in: 'header', + name: 'x-webhook-signature', + description: + 'HMAC signature of the raw request body, used to authenticate inbound webhook deliveries.', + }, + 'webhook-signature', + ) + .addTag('auth', 'Authentication and authorization endpoints') + .addTag('config', 'Client-safe testnet/mainnet runtime configuration') + .addTag('transactions', 'Transaction history and Stellar ledger queries') + .addTag( + 'soroban-events', + 'Soroban smart contract event ingestion and tracking', + ) + .addTag('users', 'User profile and account management') + .addTag('news', 'Crypto news aggregation and sentiment analysis') + .addTag('portfolio', 'Portfolio tracking and performance metrics') + .addTag('stellar', 'Stellar blockchain integration') + .addTag('search', 'Search and discovery endpoints') + .addTag('analytics', 'Aggregated usage and engagement analytics') + .addTag('app', 'Root application/service info endpoints') + .addTag('admin-audit-logs', 'Admin-only audit log retrieval') + .addTag( + 'crowdfund', + 'Soroban crowdfunding project and contribution operations', + ) + .addTag('exports', 'Asynchronous data export jobs') + .addTag('feature-flags', 'Runtime feature toggle management') + .addTag('grants', 'Quadratic-funding grant round management') + .addTag('health', 'Service and dependency health checks') + .addTag('metrics', 'Prometheus metrics endpoint (IP-allowlisted)') + .addTag('admin-models', 'ML model retraining management') + .addTag('moderation', 'Content moderation queue and actions') + .addTag( + 'notification-preferences', + 'User notification preference management', + ) + .addTag('reconciliation', 'Data reconciliation job management') + .addTag('signals', 'Trading signal subscriptions') + .addTag( + 'admin-matching-pool', + 'Admin-only Soroban quadratic-funding matching pool operations', + ) + .addTag('telegram-bot', 'Telegram bot integration') + .addTag('test', 'Diagnostic/test-only utilities (non-production)') + .addTag( + 'test-exception', + 'Diagnostic exception-handling utilities (non-production)', + ) + .addTag('treasury', 'Treasury balance and disbursement operations') + .addTag('vesting-wallet', 'Soroban vesting wallet management') + .addTag('watchlist', 'User asset watchlists') + .addTag('webhooks', 'Inbound webhook event handling') + .addTag('webhook-admin', 'Admin-only webhook secret management') + .addServer('http://localhost:3000', 'Development') + .addServer('https://api.lumenpulse.io', 'Production') + .build(); +} diff --git a/apps/backend/src/common/decorators/api-idempotency.decorator.ts b/apps/backend/src/common/decorators/api-idempotency.decorator.ts new file mode 100644 index 000000000..2c7a7de23 --- /dev/null +++ b/apps/backend/src/common/decorators/api-idempotency.decorator.ts @@ -0,0 +1,27 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiHeader, ApiResponse } from '@nestjs/swagger'; + +/** + * Documents the optional `Idempotency-Key` header handled globally by + * IdempotencyInterceptor (see common/interceptors/idempotency.interceptor.ts) + * for every POST/PUT/PATCH/DELETE request. Apply to mutating endpoints. + */ +export const ApiIdempotencyHeader = () => + applyDecorators( + ApiHeader({ + name: 'Idempotency-Key', + description: + 'Optional client-generated key that deduplicates retried requests. Replaying the same key with an identical request body returns the original cached response instead of repeating the operation.', + required: false, + }), + ApiResponse({ + status: 409, + description: + 'A request with the same Idempotency-Key is already being processed.', + }), + ApiResponse({ + status: 422, + description: + 'The Idempotency-Key was already used with a different request body.', + }), + ); diff --git a/apps/backend/src/crowdfund/crowdfund.controller.ts b/apps/backend/src/crowdfund/crowdfund.controller.ts index f4817f5bd..2b561cb54 100644 --- a/apps/backend/src/crowdfund/crowdfund.controller.ts +++ b/apps/backend/src/crowdfund/crowdfund.controller.ts @@ -25,11 +25,13 @@ import { CrowdfundProjectDto, ContributorDto, ContributionResponseDto, + ContributionRecordDto, } from './dto/crowdfund.dto'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RolesGuard } from '../auth/roles.guard'; import { Roles, UserRole } from '../auth/decorators/auth.decorators'; import { config } from '../lib/config'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; @ApiTags('crowdfund') @Controller('crowdfund') @@ -49,6 +51,7 @@ export class CrowdfundController { description: 'List of projects retrieved successfully', type: [CrowdfundProjectDto], }) + @ApiResponse({ status: 429, description: 'Too many requests' }) listProjects() { return this.svc.listProjects(); } @@ -65,6 +68,7 @@ export class CrowdfundController { type: CrowdfundProjectDto, }) @ApiResponse({ status: 404, description: 'Project not found' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) getProject(@Param('id', ParseIntPipe) id: number) { return this.svc.getProject(id); } @@ -72,6 +76,7 @@ export class CrowdfundController { @Post('projects') @UseGuards(JwtAuthGuard) @ApiBearerAuth('JWT-auth') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Create a new project', description: 'Creates a project listing. Requires authentication.', @@ -81,7 +86,9 @@ export class CrowdfundController { description: 'Project created successfully', type: CrowdfundProjectDto, }) + @ApiResponse({ status: 400, description: 'Invalid project payload' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) createProject(@Body() dto: CreateProjectDto) { return this.svc.createProject(dto); } @@ -89,6 +96,7 @@ export class CrowdfundController { // ── Contributions ────────────────────────────────────────────────────────── @Post('contribute') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Contribute to a project', description: 'Submit a contribution transaction to support a project.', @@ -98,6 +106,13 @@ export class CrowdfundController { description: 'Contribution processed successfully', type: ContributionResponseDto, }) + @ApiResponse({ + status: 400, + description: + 'Invalid contribution (e.g. non-positive amount or project not accepting contributions)', + }) + @ApiResponse({ status: 404, description: 'Project not found' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) contribute(@Body() dto: ContributeDto) { return this.svc.contribute(dto); } @@ -143,6 +158,7 @@ export class CrowdfundController { type: [ContributorDto], }) @ApiResponse({ status: 404, description: 'Project not found' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) getContributors(@Param('id', ParseIntPipe) id: number) { return this.svc.getContributors(id); } @@ -158,13 +174,16 @@ export class CrowdfundController { description: 'Balance info retrieved successfully', schema: { properties: { - totalDeposited: { type: 'string', example: '15000' }, - totalWithdrawn: { type: 'string', example: '0' }, - balance: { type: 'string', example: '15000' }, + balance: { + type: 'string', + description: 'Net balance (total deposited minus total withdrawn)', + example: '15000', + }, }, }, }) @ApiResponse({ status: 404, description: 'Project not found' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) getBalance(@Param('id', ParseIntPipe) id: number) { return this.svc.getProjectBalance(id); } @@ -180,9 +199,11 @@ export class CrowdfundController { @ApiResponse({ status: 200, description: 'Contributions list retrieved successfully', + type: [ContributionRecordDto], }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 404, description: 'Project not found' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) getMyContributions( @Param('id', ParseIntPipe) id: number, @Query('publicKey') publicKey: string, diff --git a/apps/backend/src/export/dto/export-job.dto.ts b/apps/backend/src/export/dto/export-job.dto.ts index 5b1ce4077..2b9e245ab 100644 --- a/apps/backend/src/export/dto/export-job.dto.ts +++ b/apps/backend/src/export/dto/export-job.dto.ts @@ -3,24 +3,45 @@ import { ApiProperty } from '@nestjs/swagger'; import { ExportStatus, ExportType } from '../entities/export-job.entity'; export class CreateExportJobDto { - @ApiProperty({ enum: ExportType, description: 'Type of export to generate' }) + @ApiProperty({ + enum: ExportType, + description: 'Type of export to generate', + example: ExportType.PORTFOLIO_HISTORY, + }) @IsEnum(ExportType) type: ExportType; } export class ExportJobResponseDto { - @ApiProperty() + @ApiProperty({ + description: 'Unique export job ID', + example: 'b3f1c2a4-5e6d-4f7a-8b9c-0d1e2f3a4b5c', + }) id: string; - @ApiProperty({ enum: ExportType }) + @ApiProperty({ + enum: ExportType, + description: 'Type of export being generated', + example: ExportType.PORTFOLIO_HISTORY, + }) type: ExportType; - @ApiProperty({ enum: ExportStatus }) + @ApiProperty({ + enum: ExportStatus, + description: 'Current processing status of the export job', + example: ExportStatus.COMPLETED, + }) status: ExportStatus; - @ApiProperty() + @ApiProperty({ + description: 'Timestamp the export job was created', + example: '2026-08-27T12:00:00.000Z', + }) createdAt: Date; - @ApiProperty() + @ApiProperty({ + description: 'Timestamp the export job was last updated', + example: '2026-08-27T12:05:00.000Z', + }) updatedAt: Date; } diff --git a/apps/backend/src/export/export.controller.ts b/apps/backend/src/export/export.controller.ts index a96d5e780..8befcab3b 100644 --- a/apps/backend/src/export/export.controller.ts +++ b/apps/backend/src/export/export.controller.ts @@ -25,6 +25,7 @@ import { RolesGuard } from '../auth/roles.guard'; import { Roles, UserRole } from '../auth/decorators/auth.decorators'; import { CreateExportJobDto, ExportJobResponseDto } from './dto/export-job.dto'; import { ExportStatus, ExportType } from './entities/export-job.entity'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; const ANALYTICS_TYPES = new Set([ ExportType.ONCHAIN_ANALYTICS, @@ -40,8 +41,19 @@ export class ExportController { @Post() @HttpCode(HttpStatus.ACCEPTED) - @ApiOperation({ summary: 'Create an async export job' }) - @ApiResponse({ status: 202, type: ExportJobResponseDto }) + @ApiIdempotencyHeader() + @ApiOperation({ + summary: 'Create an async export job', + description: + 'Queues a new export job (e.g. portfolio history or tax transactions) for the current user. The job runs asynchronously; poll GET /exports/:id for status.', + }) + @ApiResponse({ + status: 202, + description: 'Export job accepted and queued', + type: ExportJobResponseDto, + }) + @ApiResponse({ status: 400, description: 'Invalid export type' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Admin role required for analytics exports', @@ -95,8 +107,17 @@ export class ExportController { } @Get() - @ApiOperation({ summary: 'List recent export jobs for the current user' }) - @ApiResponse({ status: 200, type: [ExportJobResponseDto] }) + @ApiOperation({ + summary: 'List recent export jobs for the current user', + description: + 'Retrieves the current user’s export jobs, most recent first.', + }) + @ApiResponse({ + status: 200, + description: 'Export jobs retrieved successfully', + type: [ExportJobResponseDto], + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) async listJobs( @Request() req: { user: { id: string } }, ): Promise { @@ -112,7 +133,13 @@ export class ExportController { @Get(':id') @ApiOperation({ summary: 'Get export job status' }) - @ApiResponse({ status: 200, type: ExportJobResponseDto }) + @ApiResponse({ + status: 200, + description: 'Export job retrieved successfully', + type: ExportJobResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Export job not found' }) async getJob( @Param('id') id: string, @Request() req: { user: { id: string } }, @@ -129,7 +156,16 @@ export class ExportController { @Get(':id/download') @ApiOperation({ summary: 'Download the CSV for a completed export job' }) - @ApiResponse({ status: 200, description: 'CSV file download' }) + @ApiResponse({ + status: 200, + description: 'CSV file download (Content-Type: text/csv)', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ + status: 404, + description: + 'Export job not found, not owned by the current user, or not yet ready for download', + }) async downloadJob( @Param('id') id: string, @Request() req: { user: { id: string } }, diff --git a/apps/backend/src/feature-flags/feature-flags.controller.ts b/apps/backend/src/feature-flags/feature-flags.controller.ts index de3987c32..36a21c28b 100644 --- a/apps/backend/src/feature-flags/feature-flags.controller.ts +++ b/apps/backend/src/feature-flags/feature-flags.controller.ts @@ -5,6 +5,7 @@ import { UpsertFeatureFlagDto, FeatureFlagResponseDto, } from './dto/feature-flag.dto'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; @ApiTags('feature-flags') @Controller('feature-flags') @@ -62,16 +63,19 @@ export class FeatureFlagsController { } @Post() + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Create or update feature flag configuration', description: - 'Creates a new feature flag or modifies the active state of an existing one.', + 'Creates a new feature flag or modifies the active state of an existing one. ' + + 'NOTE: this endpoint currently has no authentication/authorization guard applied.', }) @ApiResponse({ status: 200, description: 'Feature flag upserted successfully', type: FeatureFlagResponseDto, }) + @ApiResponse({ status: 400, description: 'Invalid feature flag payload' }) upsert(@Body() body: UpsertFeatureFlagDto) { return this.flags.upsert( body.key, @@ -82,9 +86,12 @@ export class FeatureFlagsController { } @Delete(':key') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Delete feature flag', - description: 'Removes a feature flag from the system configuration.', + description: + 'Removes a feature flag from the system configuration. ' + + 'NOTE: this endpoint currently has no authentication/authorization guard applied.', }) @ApiResponse({ status: 200, diff --git a/apps/backend/src/grants/grants.controller.ts b/apps/backend/src/grants/grants.controller.ts index 926822f42..1d113f3b2 100644 --- a/apps/backend/src/grants/grants.controller.ts +++ b/apps/backend/src/grants/grants.controller.ts @@ -19,6 +19,7 @@ import { import { Throttle } from '@nestjs/throttler'; import { GrantsService } from './grants.service'; import { getProjectReadThrottleOverride } from '../common/rate-limit/rate-limit.config'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; import { ApproveProjectDto, CreateRoundDto, @@ -57,6 +58,7 @@ export class GrantsController { description: 'List of rounds retrieved successfully', type: [RoundDto], }) + @ApiResponse({ status: 429, description: 'Too many requests' }) listRounds() { return this.grantsService.listRounds(); } @@ -73,6 +75,7 @@ export class GrantsController { type: RoundDto, }) @ApiResponse({ status: 404, description: 'Round not found' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) getRound(@Param('id', ParseIntPipe) id: number) { return this.grantsService.getRound(id); } @@ -89,6 +92,7 @@ export class GrantsController { type: RoundSummaryDto, }) @ApiResponse({ status: 404, description: 'Round not found' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) getRoundSummary(@Param('id', ParseIntPipe) id: number) { return this.grantsService.getRoundSummary(id); } @@ -105,6 +109,7 @@ export class GrantsController { type: RoundExportDto, }) @ApiResponse({ status: 404, description: 'Round not found' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) getRoundExport(@Param('id', ParseIntPipe) id: number) { return this.grantsService.getRoundExport(id); } @@ -113,6 +118,7 @@ export class GrantsController { @UseGuards(JwtAuthGuard, RolesGuard) @ApiBearerAuth('JWT-auth') @Roles(UserRole.ADMIN) + @ApiIdempotencyHeader() @UseInterceptors(AdminAuditInterceptor) @AuditBlockchainAction({ contractField: 'tokenAddress' }) @ApiOperation({ @@ -127,6 +133,7 @@ export class GrantsController { }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) createRound(@Body() dto: CreateRoundDto) { return this.grantsService.createRound(dto); } @@ -135,6 +142,7 @@ export class GrantsController { @UseGuards(JwtAuthGuard, RolesGuard) @ApiBearerAuth('JWT-auth') @Roles(UserRole.ADMIN) + @ApiIdempotencyHeader() @UseInterceptors(AdminAuditInterceptor) @AuditBlockchainAction({ contractField: 'id' }) @ApiOperation({ @@ -149,6 +157,7 @@ export class GrantsController { @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) @ApiResponse({ status: 404, description: 'Round not found' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) finalizeRound(@Param('id', ParseIntPipe) id: number) { return this.grantsService.finalizeRound(id); } @@ -159,6 +168,7 @@ export class GrantsController { @UseGuards(JwtAuthGuard, RolesGuard) @ApiBearerAuth('JWT-auth') @Roles(UserRole.ADMIN) + @ApiIdempotencyHeader() @UseInterceptors(AdminAuditInterceptor) @AuditBlockchainAction({ contractField: 'funderPublicKey' }) @ApiOperation({ @@ -173,6 +183,7 @@ export class GrantsController { @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) @ApiResponse({ status: 404, description: 'Round not found' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) fundPool(@Body() dto: FundPoolDto) { return this.grantsService.fundPool(dto); } @@ -183,6 +194,7 @@ export class GrantsController { @UseGuards(JwtAuthGuard, RolesGuard) @ApiBearerAuth('JWT-auth') @Roles(UserRole.ADMIN, UserRole.REVIEWER) + @ApiIdempotencyHeader() @UseInterceptors(AdminAuditInterceptor) @AuditBlockchainAction({ contractField: 'roundId' }) @ApiOperation({ @@ -197,6 +209,7 @@ export class GrantsController { @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Forbidden' }) @ApiResponse({ status: 404, description: 'Round or project not found' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) approveProject(@Body() dto: ApproveProjectDto) { this.grantsService.approveProject(dto); return { success: true }; @@ -206,6 +219,7 @@ export class GrantsController { @UseGuards(JwtAuthGuard, RolesGuard) @ApiBearerAuth('JWT-auth') @Roles(UserRole.ADMIN) + @ApiIdempotencyHeader() @UseInterceptors(AdminAuditInterceptor) @AuditBlockchainAction({ contractField: 'roundId' }) @ApiOperation({ @@ -220,6 +234,7 @@ export class GrantsController { @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) @ApiResponse({ status: 404, description: 'Round or project not found' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) removeProject( @Param('roundId', ParseIntPipe) roundId: number, @Param('projectId', ParseIntPipe) projectId: number, @@ -231,6 +246,7 @@ export class GrantsController { // ΓöÇΓöÇ Contributions ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ @Post('contributions') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Record a contribution transaction', description: @@ -241,6 +257,7 @@ export class GrantsController { description: 'Contribution recorded successfully', }) @ApiResponse({ status: 400, description: 'Invalid round or project' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) recordContribution(@Body() dto: RecordContributionDto) { this.grantsService.recordContribution(dto); return { success: true }; @@ -252,6 +269,7 @@ export class GrantsController { @UseGuards(JwtAuthGuard, RolesGuard) @ApiBearerAuth('JWT-auth') @Roles(UserRole.ADMIN) + @ApiIdempotencyHeader() @UseInterceptors(AdminAuditInterceptor) @AuditBlockchainAction({ contractField: 'roundId' }) @ApiOperation({ @@ -266,6 +284,7 @@ export class GrantsController { @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) @ApiResponse({ status: 404, description: 'Round not found' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) distribute(@Body() dto: DistributeDto) { return this.grantsService.distribute(dto); } diff --git a/apps/backend/src/health/health.controller.ts b/apps/backend/src/health/health.controller.ts index d7282de4e..54a29f006 100644 --- a/apps/backend/src/health/health.controller.ts +++ b/apps/backend/src/health/health.controller.ts @@ -22,7 +22,11 @@ export class HealthController { @Get('health') @HealthCheck() - @ApiOperation({ summary: 'Returns API health and dependency status' }) + @ApiOperation({ + summary: 'Returns API health and dependency status', + description: + 'Runs configured Terminus health indicators (e.g. database, cache) and returns their aggregated status. Unauthenticated.', + }) @ApiOkResponse({ description: 'Returns a healthy or degraded response when the API is available.', diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 7e6059275..dcfe3b22d 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -2,8 +2,9 @@ import './lib/config'; import { NestFactory } from '@nestjs/core'; import { VersioningType } from '@nestjs/common'; import { AppModule } from './app.module'; -import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { SwaggerModule } from '@nestjs/swagger'; import { setupApp } from './bootstrap/app.setup'; +import { buildSwaggerConfig } from './bootstrap/swagger.config'; import { config } from './lib/config'; async function bootstrap() { @@ -16,47 +17,7 @@ async function bootstrap() { // URI versioning: /v1/config/stellar, /v2/... etc. app.enableVersioning({ type: VersioningType.URI }); - const swaggerConfig = new DocumentBuilder() - .setTitle('LumenPulse API') - .setDescription( - 'Comprehensive API documentation for LumenPulse - A decentralized crypto news aggregator and portfolio management platform built on Stellar blockchain', - ) - .setVersion('1.0') - .addBearerAuth( - { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: 'Enter JWT token', - }, - 'JWT-auth', - ) - .addTag('auth', 'Authentication and authorization endpoints') - .addTag('config', 'Client-safe testnet/mainnet runtime configuration') - .addTag('transactions', 'Transaction history and Stellar ledger queries') - .addTag( - 'soroban-events', - 'Soroban smart contract event ingestion and tracking', - ) - .addTag('users', 'User profile and account management') - .addTag('news', 'Crypto news aggregation and sentiment analysis') - .addTag('portfolio', 'Portfolio tracking and performance metrics') - .addTag('stellar', 'Stellar blockchain integration') - .addTag('search', 'Search and discovery endpoints') - .addTag( - 'demo-bootstrap', - 'Testnet demo data bootstrap endpoints (admin only, testnet only)', - ) - .addTag('contributor-feed', 'Aggregated contributor activity feed') - .addTag( - 'contributor-registry', - 'On-chain contributor registration and reputation', - ) - .addServer('http://localhost:3000', 'Development') - .addServer('https://api.lumenpulse.io', 'Production') - .build(); - - const document = SwaggerModule.createDocument(app, swaggerConfig); + const document = SwaggerModule.createDocument(app, buildSwaggerConfig()); SwaggerModule.setup('api/docs', app, document); const port = config.port; diff --git a/apps/backend/src/metrics/metrics.controller.ts b/apps/backend/src/metrics/metrics.controller.ts index 22813942f..d7d6db099 100644 --- a/apps/backend/src/metrics/metrics.controller.ts +++ b/apps/backend/src/metrics/metrics.controller.ts @@ -41,7 +41,7 @@ export class MetricsController { @ApiOperation({ summary: 'Get application metrics in Prometheus format', description: - 'Returns metrics in Prometheus text format for scraping by monitoring tools like Prometheus', + 'Returns metrics in Prometheus text format for scraping by monitoring tools like Prometheus. IP-allowlisted endpoint.', }) @ApiResponse({ status: 200, @@ -90,7 +90,8 @@ export class MetricsController { @Get('json') @ApiOperation({ summary: 'Get application metrics in JSON format', - description: 'Returns metrics as JSON for custom integrations', + description: + 'Returns metrics as JSON for custom integrations. IP-allowlisted endpoint.', }) @ApiResponse({ status: 200, @@ -124,12 +125,17 @@ export class MetricsController { @Get('health') @ApiOperation({ summary: 'Get health status', - description: 'Returns the health status of the application', + description: + 'Returns the health status of the application. IP-allowlisted endpoint.', }) @ApiResponse({ status: 200, description: 'Health status', }) + @ApiResponse({ + status: 403, + description: 'Forbidden - IP not in allowlist and no valid JWT', + }) getHealth(): Record { return { status: 'ok', diff --git a/apps/backend/src/model-retraining/model-retraining.controller.ts b/apps/backend/src/model-retraining/model-retraining.controller.ts index a937e308c..c3594a3ea 100644 --- a/apps/backend/src/model-retraining/model-retraining.controller.ts +++ b/apps/backend/src/model-retraining/model-retraining.controller.ts @@ -19,6 +19,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RolesGuard } from '../auth/roles.guard'; import { Roles } from '../auth/decorators/auth.decorators'; import { UserRole } from '../users/entities/user.entity'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; import { ModelRetrainingService, RetrainResult, @@ -107,6 +108,7 @@ export class ModelRetrainingController { */ @Post('retrain') @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Trigger model retraining (admin only)', description: diff --git a/apps/backend/src/moderation/moderation.controller.ts b/apps/backend/src/moderation/moderation.controller.ts index f8a88f246..7d601a1e0 100644 --- a/apps/backend/src/moderation/moderation.controller.ts +++ b/apps/backend/src/moderation/moderation.controller.ts @@ -28,6 +28,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RolesGuard } from '../auth/roles.guard'; import { Roles } from '../auth/decorators/auth.decorators'; import { UserRole } from '../users/entities/user.entity'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; // Unified Authenticated Request Interface interface RequestWithUser extends Request { @@ -50,12 +51,14 @@ export class ModerationController { @Post('report') @UsePipes(new ValidationPipe()) @HttpCode(HttpStatus.CREATED) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Submit a content report' }) @ApiResponse({ status: 201, description: 'Report successfully created' }) @ApiResponse({ status: 400, description: 'Bad request - duplicate report or invalid data', }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) async createReport( @Req() req: RequestWithUser, @Body() createReportDto: CreateReportDto, @@ -66,6 +69,7 @@ export class ModerationController { @Get('my-reports') @ApiOperation({ summary: 'Get reports submitted by current user' }) @ApiResponse({ status: 200, description: 'List of user reports' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) async getMyReports( @Req() req: RequestWithUser, @Query('page') page?: string, @@ -88,6 +92,8 @@ export class ModerationController { status: 200, description: 'List of all reports with pagination', }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) async getModerationQueue(@Query() query: QueryReportsDto) { return this.moderationService.getReports(query); } @@ -97,6 +103,8 @@ export class ModerationController { @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Get moderation statistics (Admin only)' }) @ApiResponse({ status: 200, description: 'Moderation queue statistics' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) async getModerationStats() { return this.moderationService.getModerationStats(); } @@ -106,6 +114,8 @@ export class ModerationController { @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Get specific report details (Admin only)' }) @ApiResponse({ status: 200, description: 'Report details' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) @ApiResponse({ status: 404, description: 'Report not found' }) async getReport(@Param('id') id: string) { return this.moderationService.getReportById(id); @@ -115,8 +125,12 @@ export class ModerationController { @UseGuards(RolesGuard) @Roles(UserRole.ADMIN) @UsePipes(new ValidationPipe()) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Update report status (Admin only)' }) @ApiResponse({ status: 200, description: 'Report updated successfully' }) + @ApiResponse({ status: 400, description: 'Invalid update data' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) @ApiResponse({ status: 404, description: 'Report not found' }) async updateReport( @Req() req: RequestWithUser, diff --git a/apps/backend/src/news/news.controller.ts b/apps/backend/src/news/news.controller.ts index c2e437be2..e6e801a3e 100644 --- a/apps/backend/src/news/news.controller.ts +++ b/apps/backend/src/news/news.controller.ts @@ -59,6 +59,7 @@ export class NewsController { description: 'Filter by article category', }) @ApiResponse({ status: 200, type: NewsArticlesResponseDto }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async getLatestArticles( @Query('limit') limit?: string, @Query('lang') lang?: string, @@ -110,6 +111,11 @@ export class NewsController { @ApiQuery({ name: 'limit', required: false, type: Number, example: 20 }) @ApiQuery({ name: 'lang', required: false, type: String, example: 'EN' }) @ApiResponse({ status: 200, type: NewsSearchResponseDto }) + @ApiResponse({ + status: 400, + description: 'Search string or source key is missing', + }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async searchArticles( @Query('q') searchString: string, @Query('source') sourceKey: string, @@ -133,6 +139,7 @@ export class NewsController { enum: ['ACTIVE', 'INACTIVE', 'ALL'], }) @ApiResponse({ status: 200, type: NewsCategoriesResponseDto }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async getCategories( @Query('status') status?: 'ACTIVE' | 'INACTIVE' | 'ALL', ): Promise { @@ -157,6 +164,7 @@ export class NewsController { }, }, }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async sentimentSummary() { return this.newsService.getSentimentSummary(); } @@ -172,6 +180,11 @@ export class NewsController { }) @ApiQuery({ name: 'guid', required: true, type: String }) @ApiResponse({ status: 200, type: SingleArticleResponseDto }) + @ApiResponse({ + status: 400, + description: 'Source key or GUID is missing', + }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async getArticle( @Query('source_key') sourceKey: string, @Query('guid') guid: string, @@ -185,6 +198,7 @@ export class NewsController { @ApiParam({ name: 'symbol', type: String, example: 'BTC' }) @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) @ApiResponse({ status: 200, type: NewsArticlesResponseDto }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async getArticlesByCoin( @Param('symbol') symbol: string, @Query('limit') limit?: string, diff --git a/apps/backend/src/notification/notification-preference.controller.ts b/apps/backend/src/notification/notification-preference.controller.ts index a36b9aecd..86b596ce0 100644 --- a/apps/backend/src/notification/notification-preference.controller.ts +++ b/apps/backend/src/notification/notification-preference.controller.ts @@ -25,9 +25,10 @@ import { } from './dto/notification-preference.dto'; import { NotificationPreference } from './notification-preference.entity'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; @ApiTags('notification-preferences') -@ApiBearerAuth() +@ApiBearerAuth('JWT-auth') @UseGuards(JwtAuthGuard) @Controller('notification-preferences') export class NotificationPreferenceController { @@ -37,6 +38,7 @@ export class NotificationPreferenceController { @Post() @HttpCode(HttpStatus.CREATED) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Create or update notification preferences', description: @@ -47,6 +49,8 @@ export class NotificationPreferenceController { description: 'Preferences created/updated successfully', type: NotificationPreferenceResponseDto, }) + @ApiResponse({ status: 400, description: 'Invalid preference payload' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) async createOrUpdate( @Body() dto: CreateNotificationPreferenceDto, ): Promise { @@ -64,6 +68,7 @@ export class NotificationPreferenceController { description: 'User notification preferences', type: NotificationPreferenceResponseDto, }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 404, description: 'Preferences not found' }) async getPreferences( @Param('userId') userId: string, @@ -83,6 +88,7 @@ export class NotificationPreferenceController { description: 'User notification preferences', type: NotificationPreferenceResponseDto, }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 404, description: 'Preferences not found' }) async getPreferencesByUserId( @Param('userId') userId: string, @@ -91,6 +97,7 @@ export class NotificationPreferenceController { } @Put(':id') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Update notification preferences', description: 'Updates notification preferences by ID', @@ -101,6 +108,8 @@ export class NotificationPreferenceController { description: 'Preferences updated successfully', type: NotificationPreferenceResponseDto, }) + @ApiResponse({ status: 400, description: 'Invalid preference payload' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 404, description: 'Preferences not found' }) async update( @Param('id') id: string, @@ -111,12 +120,14 @@ export class NotificationPreferenceController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Delete notification preferences', description: 'Deletes notification preferences (user will use defaults)', }) @ApiParam({ name: 'id', description: 'Preference ID' }) @ApiResponse({ status: 204, description: 'Preferences deleted' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 404, description: 'Preferences not found' }) async remove(@Param('id') id: string): Promise { return this.preferenceService.remove(id); @@ -143,6 +154,7 @@ export class NotificationPreferenceController { example: ['in_app', 'email'], }, }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) async getEnabledChannelsForEvent( @Param('userId') userId: string, @Param('eventCategory') eventCategory: string, diff --git a/apps/backend/src/portfolio/portfolio.controller.ts b/apps/backend/src/portfolio/portfolio.controller.ts index a6e292ed8..82c8b63cc 100644 --- a/apps/backend/src/portfolio/portfolio.controller.ts +++ b/apps/backend/src/portfolio/portfolio.controller.ts @@ -36,6 +36,7 @@ import { getPortfolioReadThrottleOverride, getPortfolioWriteThrottleOverride, } from '../common/rate-limit/rate-limit.config'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; @ApiTags('portfolio') @ApiBearerAuth('JWT-auth') @@ -63,6 +64,7 @@ export class PortfolioController { type: PortfolioSummaryWithCurrencyResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async getPortfolioSummary( @Request() req: any, @Query() query: GetPortfolioSummaryQueryDto, @@ -121,6 +123,7 @@ export class PortfolioController { type: PortfolioHistoryResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async getPortfolioHistory( @Request() req: any, @Query() query: GetPortfolioHistoryDto, @@ -137,6 +140,7 @@ export class PortfolioController { @Post('snapshot') @Throttle(getPortfolioWriteThrottleOverride()) @HttpCode(HttpStatus.CREATED) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Create portfolio snapshot', description: @@ -163,6 +167,7 @@ export class PortfolioController { }, }) @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async createSnapshot(@Request() req: any) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const userId = req.user.sub as string; @@ -180,6 +185,7 @@ export class PortfolioController { @Post('snapshots/trigger') @Throttle(getPortfolioWriteThrottleOverride()) @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Trigger snapshot creation for all users (Admin)', description: @@ -191,6 +197,7 @@ export class PortfolioController { type: TriggerSnapshotBatchResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async triggerSnapshotCreation() { const result = await this.portfolioService.triggerSnapshotCreation(); return { @@ -253,6 +260,7 @@ export class PortfolioController { type: PortfolioPerformanceResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async getPortfolioPerformance( @Request() req: any, ): Promise { @@ -273,6 +281,7 @@ export class PortfolioController { description: 'Asset allocation retrieved successfully', }) @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async getAssetAllocation(@Request() req: any) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const userId = req.user.sub as string; diff --git a/apps/backend/src/reconciliation/entities/reconciliation-job.entity.ts b/apps/backend/src/reconciliation/entities/reconciliation-job.entity.ts index 616287afa..8c38b6877 100644 --- a/apps/backend/src/reconciliation/entities/reconciliation-job.entity.ts +++ b/apps/backend/src/reconciliation/entities/reconciliation-job.entity.ts @@ -5,6 +5,7 @@ import { CreateDateColumn, Index, } from 'typeorm'; +import { ApiProperty } from '@nestjs/swagger'; export enum ReconciliationStatus { RUNNING = 'running', @@ -14,14 +15,35 @@ export enum ReconciliationStatus { export type DriftSeverity = 'none' | 'warning' | 'critical'; -export interface DriftRecord { +export class DriftRecord { + @ApiProperty({ description: 'ID of the user whose balance drifted' }) userId: string; + + @ApiProperty({ description: 'Asset code of the drifted balance' }) assetCode: string; + + @ApiProperty({ + description: 'Asset issuer, or null for the native asset', + nullable: true, + }) assetIssuer: string | null; + + @ApiProperty({ description: 'Amount stored in the local database' }) storedAmount: string; + + @ApiProperty({ description: 'Amount observed from the upstream source' }) upstreamAmount: string; + + @ApiProperty({ description: 'Difference between stored and upstream amounts' }) delta: string; + + @ApiProperty({ description: 'Whether the drift was automatically repaired' }) repaired: boolean; + + @ApiProperty({ + description: 'Severity level of the drift', + enum: ['none', 'warning', 'critical'], + }) severity: DriftSeverity; } @@ -29,9 +51,11 @@ export interface DriftRecord { @Index(['status']) @Index(['startedAt']) export class ReconciliationJob { + @ApiProperty({ description: 'Reconciliation job ID' }) @PrimaryGeneratedColumn('uuid') id: string; + @ApiProperty({ enum: ReconciliationStatus, description: 'Job status' }) @Column({ type: 'enum', enum: ReconciliationStatus, @@ -39,27 +63,42 @@ export class ReconciliationJob { }) status: ReconciliationStatus; + @ApiProperty({ description: 'Number of users processed by this job' }) @Column({ type: 'int', default: 0 }) usersProcessed: number; + @ApiProperty({ description: 'Number of balance drifts detected' }) @Column({ type: 'int', default: 0 }) driftsDetected: number; + @ApiProperty({ description: 'Number of balance drifts auto-repaired' }) @Column({ type: 'int', default: 0 }) driftsRepaired: number; + @ApiProperty({ + description: 'Details of each drift detected during this job', + type: [DriftRecord], + nullable: true, + }) @Column({ type: 'jsonb', nullable: true, default: null }) driftDetails: DriftRecord[] | null; + @ApiProperty({ + description: 'Error message if the job failed', + nullable: true, + }) @Column({ type: 'text', nullable: true, default: null }) errorMessage: string | null; + @ApiProperty({ description: 'What triggered the job (e.g. scheduled, manual)' }) @Column({ type: 'varchar', length: 50, default: 'scheduled' }) triggeredBy: string; + @ApiProperty({ description: 'When the job started' }) @CreateDateColumn({ type: 'timestamptz' }) startedAt: Date; + @ApiProperty({ description: 'When the job finished', nullable: true }) @Column({ type: 'timestamptz', nullable: true, default: null }) finishedAt: Date | null; } diff --git a/apps/backend/src/reconciliation/reconciliation.controller.ts b/apps/backend/src/reconciliation/reconciliation.controller.ts index aed9dfd60..8a1659486 100644 --- a/apps/backend/src/reconciliation/reconciliation.controller.ts +++ b/apps/backend/src/reconciliation/reconciliation.controller.ts @@ -22,6 +22,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { Roles } from '../auth/decorators/auth.decorators'; import { RolesGuard } from '../auth/roles.guard'; import { UserRole } from '../users/entities/user.entity'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; @ApiTags('reconciliation') @ApiBearerAuth('JWT-auth') @@ -33,6 +34,7 @@ export class ReconciliationController { @Post('run') @HttpCode(HttpStatus.ACCEPTED) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Manually trigger a reconciliation job (admin only)', }) @@ -41,6 +43,8 @@ export class ReconciliationController { description: 'Reconciliation job started', type: ReconciliationJob, }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) async triggerReconciliation(): Promise { return this.reconciliationService.runReconciliation('manual'); } @@ -53,6 +57,8 @@ export class ReconciliationController { description: 'List of reconciliation jobs', type: [ReconciliationJob], }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) async listJobs(@Query('limit') limit?: number): Promise { return this.reconciliationService.getRecentJobs(limit ? Number(limit) : 20); } @@ -66,6 +72,8 @@ export class ReconciliationController { description: 'Reconciliation job details', type: ReconciliationJob, }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) @ApiResponse({ status: 404, description: 'Job not found' }) async getJob(@Param('id') id: string): Promise { const job = await this.reconciliationService.getJobById(id); diff --git a/apps/backend/src/search/search.controller.ts b/apps/backend/src/search/search.controller.ts index c59639a21..3a600fe1d 100644 --- a/apps/backend/src/search/search.controller.ts +++ b/apps/backend/src/search/search.controller.ts @@ -36,6 +36,14 @@ export class SearchController { description: 'Project search results', type: ProjectSearchResponseDto, }) + @ApiResponse({ + status: 400, + description: 'Invalid query parameters', + }) + @ApiResponse({ + status: 429, + description: 'Too many requests', + }) searchProjects( @Query() query: ProjectSearchQueryDto, ): ProjectSearchResponseDto { @@ -54,6 +62,14 @@ export class SearchController { description: 'Asset search results', type: AssetDiscoveryResponseDto, }) + @ApiResponse({ + status: 400, + description: 'Invalid query parameters', + }) + @ApiResponse({ + status: 429, + description: 'Too many requests', + }) async searchAssets( @Query() query: AssetSearchQueryDto, ): Promise { @@ -72,6 +88,14 @@ export class SearchController { description: 'Ecosystem entity results', type: EcosystemSearchResponseDto, }) + @ApiResponse({ + status: 400, + description: 'Invalid query parameters', + }) + @ApiResponse({ + status: 429, + description: 'Too many requests', + }) async searchEcosystem( @Query() query: EcosystemSearchQueryDto, ): Promise { @@ -90,6 +114,14 @@ export class SearchController { description: 'Linked entities resolved from the input text', type: EntityLinkingResponseDto, }) + @ApiResponse({ + status: 400, + description: 'Invalid query parameters (e.g. missing required text field)', + }) + @ApiResponse({ + status: 429, + description: 'Too many requests', + }) async linkEntities( @Query() query: EntityLinkingQueryDto, ): Promise { diff --git a/apps/backend/src/signals/signals.controller.ts b/apps/backend/src/signals/signals.controller.ts index 604171608..91a73aff3 100644 --- a/apps/backend/src/signals/signals.controller.ts +++ b/apps/backend/src/signals/signals.controller.ts @@ -27,12 +27,18 @@ export class SignalsController { @Get('latest') @ApiOperation({ summary: 'Get the latest risk and activity signals for the current user', + description: + 'Computes and returns a deterministic set of holdings, activity, and risk signals for the authenticated user.', }) @ApiResponse({ status: 200, description: 'Latest deterministic signal summary for the current user', type: UserSignalsResponseDto, }) + @ApiResponse({ + status: 401, + description: 'Unauthorized', + }) async getLatestSignals( @Req() req: RequestWithUser, ): Promise { diff --git a/apps/backend/src/soroban-events/soroban-events.controller.ts b/apps/backend/src/soroban-events/soroban-events.controller.ts index a2941ec61..eee5dc814 100644 --- a/apps/backend/src/soroban-events/soroban-events.controller.ts +++ b/apps/backend/src/soroban-events/soroban-events.controller.ts @@ -21,6 +21,7 @@ import { IngestSorobanEventResponseDto } from './dto/ingest-soroban-event-respon import { SorobanEventsService } from './soroban-events.service'; import { SorobanEventIngestionGuard } from './guards/soroban-event-ingestion.guard'; import { VerifiedWebhookRequest } from './interfaces/soroban-webhook.interface'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; type RequestWithVerification = Request & { requestId?: string; @@ -55,6 +56,7 @@ export class SorobanEventsController { description: 'Soroban event details to ingest for processing', type: IngestSorobanEventDto, }) + @ApiIdempotencyHeader() @ApiResponse({ status: 202, description: diff --git a/apps/backend/src/stellar/controllers/matching-pool-admin.controller.ts b/apps/backend/src/stellar/controllers/matching-pool-admin.controller.ts index 03bb8ff99..7922b43ef 100644 --- a/apps/backend/src/stellar/controllers/matching-pool-admin.controller.ts +++ b/apps/backend/src/stellar/controllers/matching-pool-admin.controller.ts @@ -26,6 +26,7 @@ import { ContractAdminGuard } from '../../common/guards/contract-admin.guard'; import { ContractAdminAuditService } from '../../contract-admin/contract-admin-audit.service'; import { Roles, UserRole } from '../../auth/decorators/auth.decorators'; import { AuditBlockchainAction } from '../../admin-audit/decorators/audit-blockchain-action.decorator'; +import { ApiIdempotencyHeader } from '../../common/decorators/api-idempotency.decorator'; import { Request as ExpressRequest } from 'express'; // Define a minimal user interface for type safety @@ -40,8 +41,8 @@ interface AuthenticatedRequest extends ExpressRequest { user?: RequestUser; } -@ApiTags('Admin — Matching Pool') -@ApiBearerAuth() +@ApiTags('admin-matching-pool') +@ApiBearerAuth('JWT-auth') @UseGuards(JwtAuthGuard, ContractAdminGuard) @Roles(UserRole.ADMIN) @Controller('admin/matching-pool') @@ -55,9 +56,11 @@ export class MatchingPoolAdminController { @Post('rounds') @HttpCode(HttpStatus.CREATED) + @ApiIdempotencyHeader() @AuditBlockchainAction({ contractField: 'matchingFunds' }) @ApiOperation({ summary: 'Create a new matching round on-chain' }) @ApiResponse({ status: 201, type: RoundResponseDto }) + @ApiResponse({ status: 400, description: 'Invalid round payload' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Insufficient permissions' }) async createRound( @@ -89,11 +92,14 @@ export class MatchingPoolAdminController { @Post('rounds/:roundId/approve-project') @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() @AuditBlockchainAction({ contractField: 'projectAddress' }) @ApiOperation({ summary: 'Approve a project for a matching round' }) @ApiResponse({ status: 200, type: RoundResponseDto }) + @ApiResponse({ status: 400, description: 'Invalid approval payload' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @ApiResponse({ status: 403, description: 'Insufficient permissions' }) + @ApiResponse({ status: 404, description: 'Round not found' }) async approveProject( @Param('roundId') roundId: string, @Body() dto: ApproveProjectDto, diff --git a/apps/backend/src/stellar/dto/matching-pool.dto.ts b/apps/backend/src/stellar/dto/matching-pool.dto.ts index 633612b49..ec1a5d31a 100644 --- a/apps/backend/src/stellar/dto/matching-pool.dto.ts +++ b/apps/backend/src/stellar/dto/matching-pool.dto.ts @@ -44,8 +44,15 @@ export class ApproveProjectDto { } export class RoundResponseDto { + @ApiProperty({ description: 'Identifier of the matching round' }) roundId: string; + + @ApiProperty({ description: 'Stellar transaction hash for the operation' }) txHash: string; + + @ApiProperty({ description: 'Current status of the round' }) status: string; + + @ApiProperty({ description: 'Timestamp the round record was created' }) createdAt: Date; } diff --git a/apps/backend/src/stellar/services/soroban-rpc-client.service.ts b/apps/backend/src/stellar/services/soroban-rpc-client.service.ts index 3a4390855..aa542f8ab 100644 --- a/apps/backend/src/stellar/services/soroban-rpc-client.service.ts +++ b/apps/backend/src/stellar/services/soroban-rpc-client.service.ts @@ -70,16 +70,20 @@ const DEFAULT_OPTIONS: Required = { export class SorobanRpcClientService { private readonly logger = new Logger(SorobanRpcClientService.name); private readonly server: rpc.Server; + private readonly requestContextService: RequestContextService; // Prometheus metrics private readonly rpcLatency: Histogram; private readonly rpcErrors: Counter; private readonly rpcRequests: Counter; + private readonly registry?: Registry; constructor( - private readonly requestContextService: RequestContextService, - @Optional() private readonly registry?: Registry, + requestContextService: RequestContextService, + @Optional() registry?: Registry, ) { + this.requestContextService = requestContextService; + this.registry = registry; const rpcUrl = config.stellar.sorobanRpcUrl ?? (config.stellar.network === 'mainnet' diff --git a/apps/backend/src/stellar/stellar.controller.ts b/apps/backend/src/stellar/stellar.controller.ts index 2ffe6651f..e29444eee 100644 --- a/apps/backend/src/stellar/stellar.controller.ts +++ b/apps/backend/src/stellar/stellar.controller.ts @@ -49,6 +49,7 @@ import { } from './dto/rotate-contract-ids.dto'; import { StellarContractRotationService } from './services/stellar-contract-rotation.service'; import { ContractRotationService } from './services/contract-rotation.service'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; @ApiTags('stellar') @Controller('stellar') @@ -82,14 +83,26 @@ export class StellarController { description: 'Account balances retrieved successfully', type: AccountBalancesDto, }) + @ApiResponse({ + status: 400, + description: 'Invalid Stellar public key format', + }) @ApiResponse({ status: 404, description: 'Account not found', }) + @ApiResponse({ + status: 429, + description: 'Too many requests', + }) @ApiResponse({ status: 500, description: 'Internal server error', }) + @ApiResponse({ + status: 503, + description: 'Horizon API is unavailable', + }) async getAccountBalances( @Param('publicKey') publicKey: string, ): Promise { @@ -102,16 +115,41 @@ export class StellarController { @ApiOperation({ summary: 'Get account transactions', description: - 'Fetches recent transaction history for a given Stellar public key', + 'Fetches recent operations for a given Stellar public key directly from the Horizon API.', }) @ApiParam({ name: 'publicKey', description: 'Stellar account public key', example: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of operations to return (default: 10)', + example: 10, + }) @ApiResponse({ status: 200, - description: 'Account transactions retrieved successfully', + description: + 'Account operations retrieved successfully. The response is the raw array of Horizon operation records ' + + '(shape varies by operation type, e.g. payment, create_account, path_payment_strict_send) and is not a ' + + 'fixed schema.', + schema: { + type: 'array', + items: { type: 'object' }, + }, + }) + @ApiResponse({ + status: 400, + description: 'Invalid Stellar public key format', + }) + @ApiResponse({ + status: 429, + description: 'Too many requests', + }) + @ApiResponse({ + status: 503, + description: 'Horizon API is unavailable', }) async getAccountTransactions( @Param('publicKey') publicKey: string, @@ -139,6 +177,10 @@ export class StellarController { }, }, }) + @ApiResponse({ + status: 429, + description: 'Too many requests', + }) @ApiResponse({ status: 503, description: 'Horizon API is unavailable', @@ -201,6 +243,7 @@ export class StellarController { }) @ApiResponse({ status: 400, description: 'Invalid public key' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) @ApiResponse({ status: 503, description: 'Horizon API unavailable' }) async getTransactions( @Query('publicKey') publicKey: string, @@ -245,6 +288,10 @@ export class StellarController { status: 400, description: 'Bad request - invalid query parameters', }) + @ApiResponse({ + status: 429, + description: 'Too many requests', + }) @ApiResponse({ status: 503, description: 'Horizon API is unavailable', @@ -272,6 +319,7 @@ export class StellarController { description: 'Pre-flight validation of contract IDs. Checks that contracts are reachable and callable without making any changes. Useful for verifying new IDs before rotation.', }) + @ApiIdempotencyHeader() @ApiResponse({ status: 200, description: 'Validation completed', @@ -289,6 +337,10 @@ export class StellarController { status: 403, description: 'Forbidden (admin only)', }) + @ApiResponse({ + status: 429, + description: 'Too many requests', + }) @ApiResponse({ status: 500, description: 'Failed to connect to Soroban RPC', @@ -329,6 +381,7 @@ export class StellarController { 'All updates are atomic - validation of all contracts must pass before any changes are made. ' + 'Creates an audit log entry recording who, when, and what was changed.', }) + @ApiIdempotencyHeader() @ApiResponse({ status: 200, description: 'Contracts rotated successfully', @@ -346,6 +399,10 @@ export class StellarController { status: 403, description: 'Forbidden (admin only)', }) + @ApiResponse({ + status: 429, + description: 'Too many requests', + }) @ApiResponse({ status: 500, description: 'Failed to connect to Soroban RPC or internal error', diff --git a/apps/backend/src/telegram-bot/telegram-bot.controller.ts b/apps/backend/src/telegram-bot/telegram-bot.controller.ts index 67032bfe2..403efa19b 100644 --- a/apps/backend/src/telegram-bot/telegram-bot.controller.ts +++ b/apps/backend/src/telegram-bot/telegram-bot.controller.ts @@ -15,6 +15,7 @@ import { } from '@nestjs/swagger'; import { TelegramBotService } from './telegram-bot.service'; import { TelegramAlertType } from './telegram-subscription.entity'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; class SendAlertDto { @ApiProperty({ @@ -52,6 +53,7 @@ export class TelegramBotController { description: 'Broadcasts a price, news, or security alert to all active chats subscribed to that category.', }) + @ApiIdempotencyHeader() @ApiResponse({ status: 200, description: 'Broadcast completed successfully', @@ -62,6 +64,10 @@ export class TelegramBotController { }, }, }) + @ApiResponse({ + status: 400, + description: 'Invalid alertType or missing message', + }) async broadcast(@Body() dto: SendAlertDto) { await this.telegramBotService.broadcastAlert(dto.alertType, dto.message); return { success: true, message: 'Broadcast sent' }; diff --git a/apps/backend/src/test-exception.controller.ts b/apps/backend/src/test-exception.controller.ts index 5c8ab71e6..edea86994 100644 --- a/apps/backend/src/test-exception.controller.ts +++ b/apps/backend/src/test-exception.controller.ts @@ -19,6 +19,7 @@ import { HealthResponse, } from './sentiment/sentiment.service'; import { config } from './lib/config'; +import { ApiIdempotencyHeader } from './common/decorators/api-idempotency.decorator'; // DTO for sentiment analysis class AnalyzeDto { @@ -249,6 +250,7 @@ export class TestExceptionController { description: 'Submits text to the Python data service to calculate polarity scores.', }) + @ApiIdempotencyHeader() @ApiResponse({ status: 200, description: 'Sentiment calculated successfully', @@ -300,6 +302,7 @@ export class TestExceptionController { description: 'Runs multiple hardcoded test phrases to verify sentiment classifications.', }) + @ApiIdempotencyHeader() @ApiResponse({ status: 200, description: 'Test suite ran successfully', diff --git a/apps/backend/src/test/test.controller.ts b/apps/backend/src/test/test.controller.ts index 06759e653..b1c54fabf 100644 --- a/apps/backend/src/test/test.controller.ts +++ b/apps/backend/src/test/test.controller.ts @@ -11,6 +11,7 @@ import { import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; import { FeatureFlag } from '../feature-flags/feature-flag.decorator'; import { FeatureFlagGuard } from '../feature-flags/feature-flag.guard'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; @ApiTags('test') @Controller('test') @@ -37,6 +38,7 @@ export class TestController { summary: 'Submit diagnostic data', description: 'Echos submitted payload back with a timestamp for testing.', }) + @ApiIdempotencyHeader() @ApiResponse({ status: 200, description: 'Data submitted successfully', @@ -111,6 +113,7 @@ export class TestController { description: 'Diagnostic item ID', example: 'diag_123', }) + @ApiIdempotencyHeader() @ApiResponse({ status: 200, description: 'Data updated successfully', @@ -143,6 +146,7 @@ export class TestController { description: 'Diagnostic item ID to delete', example: 'diag_123', }) + @ApiIdempotencyHeader() @ApiResponse({ status: 200, description: 'Data deleted successfully', diff --git a/apps/backend/src/treasury/treasury.controller.ts b/apps/backend/src/treasury/treasury.controller.ts index 266595145..316b8ed89 100644 --- a/apps/backend/src/treasury/treasury.controller.ts +++ b/apps/backend/src/treasury/treasury.controller.ts @@ -24,6 +24,7 @@ import { ContractAdminGuard } from '../common/guards/contract-admin.guard'; import { ContractAdminAuditService } from '../contract-admin/contract-admin-audit.service'; import { Roles } from '../auth/decorators/auth.decorators'; import { UserRole } from '../users/entities/user.entity'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; import { AllocateBudgetDto } from './dto/allocate-budget.dto'; import { AllocateBudgetResponseDto, @@ -72,6 +73,7 @@ export class TreasuryController { @Roles(UserRole.ADMIN) @AuditBlockchainAction({ contractField: 'beneficiary' }) @ApiBearerAuth('JWT-auth') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Allocate a treasury budget and start a stream (admin only)', description: diff --git a/apps/backend/src/users/dto/profile-response.dto.ts b/apps/backend/src/users/dto/profile-response.dto.ts index 08ddc9ec2..0f7e75c3f 100644 --- a/apps/backend/src/users/dto/profile-response.dto.ts +++ b/apps/backend/src/users/dto/profile-response.dto.ts @@ -1,16 +1,38 @@ -import { UserPreferences } from '../entities/user.entity'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import type { UserPreferences } from '../entities/user.entity'; export class ProfileResponseDto { + @ApiProperty({ description: 'User ID' }) id: string; + + @ApiProperty({ description: 'User email address' }) email: string; + + @ApiPropertyOptional({ description: 'First name' }) firstName?: string; + + @ApiPropertyOptional({ description: 'Last name' }) lastName?: string; + + @ApiPropertyOptional({ description: 'Display name shown in the UI' }) displayName?: string; + + @ApiPropertyOptional({ description: 'User bio/description' }) bio?: string; + + @ApiPropertyOptional({ description: 'URL to user avatar image' }) avatarUrl?: string; + + @ApiPropertyOptional({ description: 'Primary linked Stellar public key' }) stellarPublicKey?: string; + + @ApiPropertyOptional({ description: 'User notification/currency preferences' }) preferences?: UserPreferences; + + @ApiProperty({ description: 'When the user account was created' }) createdAt: Date; + + @ApiProperty({ description: 'When the user account was last updated' }) updatedAt: Date; constructor(partial: Partial) { diff --git a/apps/backend/src/users/dto/user-admin-response.dto.ts b/apps/backend/src/users/dto/user-admin-response.dto.ts new file mode 100644 index 000000000..cb0024a76 --- /dev/null +++ b/apps/backend/src/users/dto/user-admin-response.dto.ts @@ -0,0 +1,68 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { User, UserRole } from '../entities/user.entity'; +import type { UserPreferences } from '../entities/user.entity'; + +/** + * Admin-facing user representation. Deliberately omits `passwordHash` and + * `twoFactorSecret` — those must never leave the service layer, even to + * admin-only endpoints. + */ +export class UserAdminResponseDto { + @ApiProperty({ description: 'User ID' }) + id: string; + + @ApiPropertyOptional({ description: 'User email address', nullable: true }) + email: string | null; + + @ApiPropertyOptional({ description: 'First name', nullable: true }) + firstName: string | null; + + @ApiPropertyOptional({ description: 'Last name', nullable: true }) + lastName: string | null; + + @ApiPropertyOptional({ description: 'Display name shown in the UI', nullable: true }) + displayName: string | null; + + @ApiPropertyOptional({ description: 'User bio/description', nullable: true }) + bio: string | null; + + @ApiPropertyOptional({ description: 'URL to user avatar image', nullable: true }) + avatarUrl: string | null; + + @ApiPropertyOptional({ + description: 'Primary linked Stellar public key', + nullable: true, + }) + stellarPublicKey: string | null; + + @ApiProperty({ enum: UserRole, description: 'User role' }) + role: UserRole; + + @ApiProperty({ description: 'User notification/currency preferences' }) + preferences: UserPreferences; + + @ApiProperty({ description: 'Whether two-factor authentication is enabled' }) + twoFactorEnabled: boolean; + + @ApiProperty({ description: 'When the user account was created' }) + createdAt: Date; + + @ApiProperty({ description: 'When the user account was last updated' }) + updatedAt: Date; + + constructor(user: User) { + this.id = user.id; + this.email = user.email; + this.firstName = user.firstName; + this.lastName = user.lastName; + this.displayName = user.displayName; + this.bio = user.bio; + this.avatarUrl = user.avatarUrl; + this.stellarPublicKey = user.stellarPublicKey; + this.role = user.role; + this.preferences = user.preferences; + this.twoFactorEnabled = user.twoFactorEnabled; + this.createdAt = user.createdAt; + this.updatedAt = user.updatedAt; + } +} diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 76d6c09ec..01a94404f 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -36,6 +36,7 @@ import { StellarAccountResponseDto } from './dto/stellar-account-response.dto'; import { UpdateStellarAccountLabelDto } from './dto/update-stellar-account-label.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; import { ProfileResponseDto } from './dto/profile-response.dto'; +import { UserAdminResponseDto } from './dto/user-admin-response.dto'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RolesGuard } from '../auth/roles.guard'; import { Roles } from '../auth/decorators/auth.decorators'; @@ -43,6 +44,7 @@ import { UserRole } from './entities/user.entity'; import { FileInterceptor } from '@nestjs/platform-express'; import { SharpPipe } from '../common/pipes/sharp.pipe'; import { AuditLogAction } from '../audit/decorators/audit-log.decorator'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; // Unified Authenticated Request Interface interface RequestWithUser extends Request { @@ -110,26 +112,50 @@ export class UsersController { @Get() @UseGuards(RolesGuard) @Roles(UserRole.ADMIN) - @ApiOperation({ summary: 'Get all users' }) - @ApiResponse({ status: 200, description: 'List of all users', type: [User] }) - async findAll(): Promise { - return this.usersService.findAll(); + @ApiOperation({ summary: 'Get all users (admin only)' }) + @ApiResponse({ + status: 200, + description: 'List of all users', + type: [UserAdminResponseDto], + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) + async findAll(): Promise { + const users = await this.usersService.findAll(); + return users.map((user) => new UserAdminResponseDto(user)); } @Get(':id') @UseGuards(RolesGuard) @Roles(UserRole.ADMIN) - @ApiOperation({ summary: 'Get user by ID' }) - @ApiResponse({ status: 200, description: 'User found', type: User }) + @ApiOperation({ summary: 'Get user by ID (admin only)' }) + @ApiResponse({ + status: 200, + description: 'User found', + type: UserAdminResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) @ApiResponse({ status: 404, description: 'User not found' }) - async findById(@Param('id') id: string): Promise { - return this.usersService.findById(id); + async findById(@Param('id') id: string): Promise { + const user = await this.usersService.findById(id); + if (!user) { + throw new NotFoundException('User not found'); + } + return new UserAdminResponseDto(user); } // --- PROFILE MANAGEMENT (From Upstream) --- @Get('me') @ApiOperation({ summary: 'Get current user profile' }) + @ApiResponse({ + status: 200, + description: 'Current user profile', + type: ProfileResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'User not found' }) async getProfile(@Req() req: RequestWithUser): Promise { const userId = req.user.id; const user = await this.usersService.findById(userId); @@ -143,7 +169,16 @@ export class UsersController { @Patch('me') @UsePipes(new ValidationPipe()) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Update current user profile' }) + @ApiResponse({ + status: 200, + description: 'Profile updated successfully', + type: ProfileResponseDto, + }) + @ApiResponse({ status: 400, description: 'Invalid profile payload' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'User not found' }) async updateProfile( @Req() req: RequestWithUser, @Body() updateProfileDto: UpdateProfileDto, @@ -182,8 +217,11 @@ export class UsersController { @Post('me/accounts') @AuditLogAction('account_linking') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Link a new Stellar account to user profile' }) @ApiResponse({ status: 201, type: StellarAccountResponseDto }) + @ApiResponse({ status: 400, description: 'Invalid Stellar account payload' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) async addStellarAccount( @Req() req: RequestWithUser, @Body() dto: LinkStellarAccountDto, @@ -194,6 +232,7 @@ export class UsersController { @Get('me/accounts') @ApiOperation({ summary: 'Get all linked Stellar accounts for current user' }) @ApiResponse({ status: 200, type: [StellarAccountResponseDto] }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) async getMyStellarAccounts( @Req() req: RequestWithUser, ): Promise { @@ -203,6 +242,8 @@ export class UsersController { @Get('me/accounts/:id') @ApiOperation({ summary: 'Get a specific Stellar account for current user' }) @ApiResponse({ status: 200, type: StellarAccountResponseDto }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Stellar account not found' }) async getMyStellarAccount( @Req() req: RequestWithUser, @Param('id') accountId: string, @@ -212,7 +253,11 @@ export class UsersController { @Delete('me/accounts/:id') @HttpCode(HttpStatus.NO_CONTENT) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Unlink a Stellar account from current user' }) + @ApiResponse({ status: 204, description: 'Stellar account unlinked' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Stellar account not found' }) async removeMyStellarAccount( @Req() req: RequestWithUser, @Param('id') accountId: string, @@ -221,7 +266,12 @@ export class UsersController { } @Patch('me/accounts/:id/label') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Update account label for current user' }) + @ApiResponse({ status: 200, type: StellarAccountResponseDto }) + @ApiResponse({ status: 400, description: 'Invalid label payload' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Stellar account not found' }) async updateMyStellarAccountLabel( @Req() req: RequestWithUser, @Param('id') accountId: string, @@ -236,7 +286,11 @@ export class UsersController { @Patch('me/avatar') @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Upload user profile image' }) + @ApiResponse({ status: 200, description: 'Profile image uploaded' }) + @ApiResponse({ status: 400, description: 'Invalid or unsupported image file' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @UseInterceptors(FileInterceptor('avatar')) async uploadAvatar( @Param('id') accountId: string, @@ -258,7 +312,11 @@ export class UsersController { @Post('me/accounts/:id/primary') @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Set as primary account for current user' }) + @ApiResponse({ status: 200, description: 'Primary account updated' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Stellar account not found' }) async setMyPrimaryAccount( @Req() req: RequestWithUser, @Param('id') accountId: string, diff --git a/apps/backend/src/verification/verification.controller.ts b/apps/backend/src/verification/verification.controller.ts index 13ecea3a2..4e12c7326 100644 --- a/apps/backend/src/verification/verification.controller.ts +++ b/apps/backend/src/verification/verification.controller.ts @@ -38,6 +38,7 @@ import { Roles } from '../auth/decorators/auth.decorators'; import { UserRole } from '../users/entities/user.entity'; import { AuditBlockchainAction } from '../admin-audit/decorators/audit-blockchain-action.decorator'; import { AdminAuditInterceptor } from '../admin-audit/interceptors/admin-audit.interceptor'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; @ApiTags('verification') @Controller('verification') @@ -65,6 +66,7 @@ export class VerificationController { @Roles(UserRole.ADMIN) @UseInterceptors(AdminAuditInterceptor) @AuditBlockchainAction({}) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Update verification registry config', description: @@ -75,6 +77,10 @@ export class VerificationController { description: 'Registry configuration updated successfully', type: RegistryConfigDto, }) + @ApiResponse({ + status: 400, + description: 'quorumThreshold must be >= 1', + }) @ApiResponse({ status: 401, description: 'Unauthorized' }) updateConfig(@Body() dto: UpdateConfigDto) { return this.svc.updateConfig(dto); @@ -138,6 +144,7 @@ export class VerificationController { @Roles(UserRole.ADMIN) @UseInterceptors(AdminAuditInterceptor) @AuditBlockchainAction({ contractField: 'ownerPublicKey' }) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Register a project for verification', description: @@ -148,13 +155,14 @@ export class VerificationController { description: 'Project registered successfully', type: ProjectVerificationDto, }) + @ApiResponse({ status: 400, description: 'Project already registered' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) - @ApiResponse({ status: 409, description: 'Project already registered' }) registerProject(@Body() dto: RegisterProjectDto) { return this.svc.registerProject(dto); } @Post('vote') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Cast a verification vote', description: @@ -165,8 +173,16 @@ export class VerificationController { description: 'Vote cast and tallied successfully', type: VoteResultDto, }) - @ApiResponse({ status: 400, description: 'Invalid project or voter key' }) - @ApiResponse({ status: 409, description: 'Voter already voted' }) + @ApiResponse({ + status: 400, + description: + 'Project is no longer accepting votes, or this voter has already voted on it', + }) + @ApiResponse({ + status: 403, + description: 'Voter weight is below the configured minimum', + }) + @ApiResponse({ status: 404, description: 'Project not found' }) castVote(@Body() dto: CastVoteDto) { return this.svc.castVote(dto); } @@ -177,6 +193,7 @@ export class VerificationController { @Roles(UserRole.ADMIN) @UseInterceptors(AdminAuditInterceptor) @AuditBlockchainAction({ contractField: 'projectId' }) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Override project verification status', description: @@ -212,9 +229,11 @@ export class VerificationController { @Get('submissions/:id') @ApiOperation({ summary: 'Get project submission details', + description: 'Retrieves a single project submission record by its ID.', }) @ApiResponse({ status: 200, + description: 'Submission record retrieved successfully', type: ProjectSubmissionDto, }) @ApiResponse({ status: 404, description: 'Submission not found' }) @@ -225,6 +244,7 @@ export class VerificationController { @Post('submissions') @UseGuards(JwtAuthGuard) @ApiBearerAuth('JWT-auth') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Save submission draft', description: @@ -235,6 +255,11 @@ export class VerificationController { description: 'Submission draft saved', type: ProjectSubmissionDto, }) + @ApiResponse({ + status: 400, + description: 'Submission is already published and cannot be edited', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) upsertSubmission(@Body() dto: UpsertSubmissionDto) { return this.svc.upsertSubmission(dto); } @@ -242,9 +267,23 @@ export class VerificationController { @Post('submissions/:id/submit') @UseGuards(JwtAuthGuard) @ApiBearerAuth('JWT-auth') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Submit draft for review', + description: + 'Moves a submission from draft (or changes-requested) into the review state.', }) + @ApiResponse({ + status: 201, + description: 'Submission moved to review', + type: ProjectSubmissionDto, + }) + @ApiResponse({ + status: 400, + description: 'Submission is already published or already in review', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Submission not found' }) submitForReview(@Param('id', ParseIntPipe) id: number) { return this.svc.submitForReview(id); } @@ -252,9 +291,23 @@ export class VerificationController { @Post('submissions/:id/request-changes') @UseGuards(JwtAuthGuard) @ApiBearerAuth('JWT-auth') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Request changes on submission', + description: + 'Reviewer/admin action that moves an in-review submission back to changes-requested.', + }) + @ApiResponse({ + status: 201, + description: 'Submission returned to changes-requested state', + type: ProjectSubmissionDto, + }) + @ApiResponse({ + status: 400, + description: 'Submission must be in review to request changes', }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Submission not found' }) requestChanges( @Param('id', ParseIntPipe) id: number, @Body() dto: SubmissionActionDto, @@ -265,9 +318,23 @@ export class VerificationController { @Post('submissions/:id/approve') @UseGuards(JwtAuthGuard) @ApiBearerAuth('JWT-auth') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Approve submission for publishing', + description: + 'Reviewer/admin action that moves an in-review submission to approved.', + }) + @ApiResponse({ + status: 201, + description: 'Submission approved', + type: ProjectSubmissionDto, + }) + @ApiResponse({ + status: 400, + description: 'Submission must be in review to approve', }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Submission not found' }) approveSubmission( @Param('id', ParseIntPipe) id: number, @Body() dto: SubmissionActionDto, @@ -278,9 +345,23 @@ export class VerificationController { @Post('submissions/:id/publish') @UseGuards(JwtAuthGuard) @ApiBearerAuth('JWT-auth') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Publish approved submission', + description: + 'Reviewer/admin action that moves an approved submission to published.', }) + @ApiResponse({ + status: 201, + description: 'Submission published', + type: ProjectSubmissionDto, + }) + @ApiResponse({ + status: 400, + description: 'Submission must be approved before publishing', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Submission not found' }) publishSubmission( @Param('id', ParseIntPipe) id: number, @Body() dto: SubmissionActionDto, diff --git a/apps/backend/src/vesting-wallet/vesting-wallet.controller.ts b/apps/backend/src/vesting-wallet/vesting-wallet.controller.ts index a3d7d2a11..9ac00b02f 100644 --- a/apps/backend/src/vesting-wallet/vesting-wallet.controller.ts +++ b/apps/backend/src/vesting-wallet/vesting-wallet.controller.ts @@ -19,6 +19,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RolesGuard } from '../auth/roles.guard'; import { Roles } from '../auth/decorators/auth.decorators'; import { UserRole } from '../users/entities/user.entity'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; import { CreateVestingDto, CreateVestingWithMilestoneDto, @@ -39,6 +40,7 @@ export class VestingWalletController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @ApiBearerAuth('JWT-auth') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Create a vesting schedule (admin only)', description: @@ -72,6 +74,7 @@ export class VestingWalletController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @ApiBearerAuth('JWT-auth') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Create a milestone-linked vesting schedule (admin only)', description: diff --git a/apps/backend/src/watchlist/watchlist.controller.ts b/apps/backend/src/watchlist/watchlist.controller.ts index 5f6debc41..d540eaf2a 100644 --- a/apps/backend/src/watchlist/watchlist.controller.ts +++ b/apps/backend/src/watchlist/watchlist.controller.ts @@ -22,6 +22,7 @@ import { } from '@nestjs/swagger'; import { WatchlistService } from './watchlist.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; import { AddToWatchlistDto, UpdateWatchlistDto, @@ -72,6 +73,7 @@ export class WatchlistController { @Post() @Throttle(getWatchlistWriteThrottleOverride()) @HttpCode(HttpStatus.CREATED) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Add item to watchlist', description: @@ -96,6 +98,7 @@ export class WatchlistController { @Post('toggle') @Throttle(getWatchlistWriteThrottleOverride()) @HttpCode(HttpStatus.OK) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Toggle watchlist item', description: @@ -123,6 +126,7 @@ export class WatchlistController { @Patch(':id') @Throttle(getWatchlistWriteThrottleOverride()) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Update watchlist item', description: "Update a watchlist item's notes, image, name, or sort order", @@ -147,6 +151,7 @@ export class WatchlistController { @Delete(':id') @Throttle(getWatchlistWriteThrottleOverride()) @HttpCode(HttpStatus.NO_CONTENT) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Remove item from watchlist', description: @@ -166,6 +171,7 @@ export class WatchlistController { @Patch('reorder') @Throttle(getWatchlistWriteThrottleOverride()) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Reorder watchlist items', description: diff --git a/apps/backend/src/webhook/webhook-admin.controller.ts b/apps/backend/src/webhook/webhook-admin.controller.ts index 97e48ec10..950659c2c 100644 --- a/apps/backend/src/webhook/webhook-admin.controller.ts +++ b/apps/backend/src/webhook/webhook-admin.controller.ts @@ -22,7 +22,12 @@ import { UpdateWebhookProviderDto, WebhookProviderResponseDto, } from './dto/webhook-provider.dto'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; +// SECURITY NOTE: this controller has no auth guard (no JwtAuthGuard/RolesGuard) +// despite managing webhook provider secrets/public keys. This is a pre-existing +// gap tracked separately — documented here (and per-endpoint) as-is; adding a +// guard is a behavior change outside the scope of this docs-only pass. @ApiTags('webhook-admin') @Controller('webhooks/admin/providers') export class WebhookAdminController { @@ -95,9 +100,12 @@ export class WebhookAdminController { @Post() @HttpCode(HttpStatus.CREATED) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Register a new webhook provider', - description: 'Adds a new webhook provider configuration at runtime', + description: + 'Adds a new webhook provider configuration at runtime. ' + + 'NOTE: this endpoint currently has no auth guard applied — see module-level security caveat.', }) @ApiResponse({ status: 201, @@ -158,9 +166,12 @@ export class WebhookAdminController { } @Put(':name') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Update webhook provider configuration', - description: 'Updates an existing webhook provider configuration', + description: + 'Updates an existing webhook provider configuration. ' + + 'NOTE: this endpoint currently has no auth guard applied — see module-level security caveat.', }) @ApiParam({ name: 'name', description: 'Provider name' }) @ApiResponse({ @@ -211,9 +222,12 @@ export class WebhookAdminController { @Delete(':name') @HttpCode(HttpStatus.NO_CONTENT) + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Remove a webhook provider', - description: 'Removes a webhook provider configuration', + description: + 'Removes a webhook provider configuration (disables it at runtime). ' + + 'NOTE: this endpoint currently has no auth guard applied — see module-level security caveat.', }) @ApiParam({ name: 'name', description: 'Provider name' }) @ApiResponse({ status: 204, description: 'Provider removed successfully' }) diff --git a/apps/backend/src/webhook/webhook.controller.ts b/apps/backend/src/webhook/webhook.controller.ts index db7aa256c..cfa6249cf 100644 --- a/apps/backend/src/webhook/webhook.controller.ts +++ b/apps/backend/src/webhook/webhook.controller.ts @@ -16,6 +16,7 @@ import { WebhookVerificationGuard, WebhookProvider, } from './webhook-verification.guard'; +import { ApiIdempotencyHeader } from '../common/decorators/api-idempotency.decorator'; interface RawRequest { rawBody?: Buffer; @@ -30,6 +31,7 @@ export class WebhookController { @HttpCode(HttpStatus.OK) @UseGuards(WebhookVerificationGuard) @WebhookProvider('data-processing') + @ApiIdempotencyHeader() @ApiOperation({ summary: 'Receive data-processing intelligence events', description: diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index f5971197f..4772068e6 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -16,6 +16,7 @@ "baseUrl": "./", "incremental": true, "skipLibCheck": true, + "ignoreDeprecations": "6.0", "strictNullChecks": true, "forceConsistentCasingInFileNames": true, "noImplicitAny": false, diff --git a/document/backend-contributing.md b/document/backend-contributing.md index 329a3c265..a4fc7a5a0 100644 --- a/document/backend-contributing.md +++ b/document/backend-contributing.md @@ -1,6 +1,6 @@ # Backend Contribution Guide -This guide covers app-specific standards for `apps/backend`. The backend integrates with Stellar/Soroban. For migration details, see [Stellar Migration Notes](STELLAR_MIGRATION_NOTES.md). +This guide covers app-specific standards for `apps/backend`. The backend integrates with Stellar/Soroban. For migration details, see [Stellar Migration Notes](STELLAR_MIGRATION_NOTES.md). For the committed OpenAPI spec artifact and how to regenerate it, see [OpenAPI Spec](../apps/backend/document/openapi-spec.md). ## Setup @@ -23,6 +23,9 @@ npm run test:e2e # Run in watch mode npm run start:dev + +# Regenerate the committed OpenAPI spec after changing controllers/DTOs +npm run openapi:generate ``` ## Standards @@ -50,3 +53,4 @@ To prevent accidental breaking changes to client-facing APIs, we use schema snap - **Intentional Updates**: If the API change is deliberate, update the snapshot by running `npm run test -- -u` inside `apps/backend` and commit the modified `.snap` file. - **Coverage**: Currently covers the `users` route group (`apps/backend/src/users/users-schema.spec.ts`). - **Extending Coverage**: To cover a new module, create a `-schema.spec.ts` test that isolates the module's controller and snapshots its OpenAPI document, following the pattern in `users-schema.spec.ts`. +- Controller/DTO changes include a regenerated `apps/backend/openapi/openapi.json` (`npm run openapi:generate`) — CI fails if it's stale. From 275f0788c1ed8154ade2b2ac164054964eb72c36 Mon Sep 17 00:00:00 2001 From: datagirl Date: Fri, 28 Aug 2026 18:44:44 +0100 Subject: [PATCH 2/6] remove stale merge conflict markers --- apps/backend/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 4772068e6..d6e1d00b6 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -16,7 +16,7 @@ "baseUrl": "./", "incremental": true, "skipLibCheck": true, - "ignoreDeprecations": "6.0", + "ignoreDeprecations": "5.0", "strictNullChecks": true, "forceConsistentCasingInFileNames": true, "noImplicitAny": false, From 631ae8c4e52dbce9338943c34d6a90427c69391b Mon Sep 17 00:00:00 2001 From: datagirl Date: Fri, 28 Aug 2026 19:28:40 +0100 Subject: [PATCH 3/6] remove stale merge conflict markers --- apps/backend/src/auth/auth.service.ts | 2 - .../__snapshots__/users-schema.spec.ts.snap | 338 +++++++++++++++++- apps/backend/src/users/users.controller.ts | 4 +- 3 files changed, 324 insertions(+), 20 deletions(-) diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index 141077323..0e8a78545 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -6,8 +6,6 @@ import { UnauthorizedException, BadRequestException, NotFoundException, - Inject, - forwardRef, OnModuleDestroy, } from '@nestjs/common'; import { UsersService } from '../users/users.service'; diff --git a/apps/backend/src/users/__snapshots__/users-schema.spec.ts.snap b/apps/backend/src/users/__snapshots__/users-schema.spec.ts.snap index b39f181cd..61349b0e5 100644 --- a/apps/backend/src/users/__snapshots__/users-schema.spec.ts.snap +++ b/apps/backend/src/users/__snapshots__/users-schema.spec.ts.snap @@ -12,7 +12,7 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/User", + "$ref": "#/components/schemas/UserAdminResponseDto", }, "type": "array", }, @@ -20,13 +20,19 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A }, "description": "List of all users", }, + "401": { + "description": "Unauthorized", + }, + "403": { + "description": "Forbidden (admin only)", + }, }, "security": [ { "JWT-auth": [], }, ], - "summary": "Get all users", + "summary": "Get all users (admin only)", "tags": [ "users", ], @@ -38,7 +44,20 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A "parameters": [], "responses": { "200": { - "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileResponseDto", + }, + }, + }, + "description": "Current user profile", + }, + "401": { + "description": "Unauthorized", + }, + "404": { + "description": "User not found", }, }, "security": [ @@ -53,7 +72,17 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A }, "patch": { "operationId": "UsersController_updateProfile", - "parameters": [], + "parameters": [ + { + "description": "Optional client-generated key that deduplicates retried requests. Replaying the same key with an identical request body returns the original cached response instead of repeating the operation.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "type": "string", + }, + }, + ], "requestBody": { "content": { "application/json": { @@ -66,7 +95,29 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A }, "responses": { "200": { - "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileResponseDto", + }, + }, + }, + "description": "Profile updated successfully", + }, + "400": { + "description": "Invalid profile payload", + }, + "401": { + "description": "Unauthorized", + }, + "404": { + "description": "User not found", + }, + "409": { + "description": "A request with the same Idempotency-Key is already being processed.", + }, + "422": { + "description": "The Idempotency-Key was already used with a different request body.", }, }, "security": [ @@ -98,6 +149,9 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A }, "description": "", }, + "401": { + "description": "Unauthorized", + }, }, "security": [ { @@ -111,7 +165,17 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A }, "post": { "operationId": "UsersController_addStellarAccount", - "parameters": [], + "parameters": [ + { + "description": "Optional client-generated key that deduplicates retried requests. Replaying the same key with an identical request body returns the original cached response instead of repeating the operation.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "type": "string", + }, + }, + ], "requestBody": { "content": { "application/json": { @@ -133,6 +197,18 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A }, "description": "", }, + "400": { + "description": "Invalid Stellar account payload", + }, + "401": { + "description": "Unauthorized", + }, + "409": { + "description": "A request with the same Idempotency-Key is already being processed.", + }, + "422": { + "description": "The Idempotency-Key was already used with a different request body.", + }, }, "security": [ { @@ -157,10 +233,31 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A "type": "string", }, }, + { + "description": "Optional client-generated key that deduplicates retried requests. Replaying the same key with an identical request body returns the original cached response instead of repeating the operation.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "type": "string", + }, + }, ], "responses": { "204": { - "description": "", + "description": "Stellar account unlinked", + }, + "401": { + "description": "Unauthorized", + }, + "404": { + "description": "Stellar account not found", + }, + "409": { + "description": "A request with the same Idempotency-Key is already being processed.", + }, + "422": { + "description": "The Idempotency-Key was already used with a different request body.", }, }, "security": [ @@ -196,6 +293,12 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A }, "description": "", }, + "401": { + "description": "Unauthorized", + }, + "404": { + "description": "Stellar account not found", + }, }, "security": [ { @@ -220,6 +323,15 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A "type": "string", }, }, + { + "description": "Optional client-generated key that deduplicates retried requests. Replaying the same key with an identical request body returns the original cached response instead of repeating the operation.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "type": "string", + }, + }, ], "requestBody": { "content": { @@ -233,8 +345,30 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A }, "responses": { "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StellarAccountResponseDto", + }, + }, + }, "description": "", }, + "400": { + "description": "Invalid label payload", + }, + "401": { + "description": "Unauthorized", + }, + "404": { + "description": "Stellar account not found", + }, + "409": { + "description": "A request with the same Idempotency-Key is already being processed.", + }, + "422": { + "description": "The Idempotency-Key was already used with a different request body.", + }, }, "security": [ { @@ -259,10 +393,31 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A "type": "string", }, }, + { + "description": "Optional client-generated key that deduplicates retried requests. Replaying the same key with an identical request body returns the original cached response instead of repeating the operation.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "type": "string", + }, + }, ], "responses": { "200": { - "description": "", + "description": "Primary account updated", + }, + "401": { + "description": "Unauthorized", + }, + "404": { + "description": "Stellar account not found", + }, + "409": { + "description": "A request with the same Idempotency-Key is already being processed.", + }, + "422": { + "description": "The Idempotency-Key was already used with a different request body.", }, }, "security": [ @@ -281,9 +436,10 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A "operationId": "UsersController_uploadAvatar", "parameters": [ { - "in": "path", - "name": "id", - "required": true, + "description": "Optional client-generated key that deduplicates retried requests. Replaying the same key with an identical request body returns the original cached response instead of repeating the operation.", + "in": "header", + "name": "Idempotency-Key", + "required": false, "schema": { "type": "string", }, @@ -291,7 +447,19 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A ], "responses": { "200": { - "description": "", + "description": "Profile image uploaded", + }, + "400": { + "description": "Invalid or unsupported image file", + }, + "401": { + "description": "Unauthorized", + }, + "409": { + "description": "A request with the same Idempotency-Key is already being processed.", + }, + "422": { + "description": "The Idempotency-Key was already used with a different request body.", }, }, "security": [ @@ -323,12 +491,18 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/User", + "$ref": "#/components/schemas/UserAdminResponseDto", }, }, }, "description": "User found", }, + "401": { + "description": "Unauthorized", + }, + "403": { + "description": "Forbidden (admin only)", + }, "404": { "description": "User not found", }, @@ -338,7 +512,7 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A "JWT-auth": [], }, ], - "summary": "Get user by ID", + "summary": "Get user by ID (admin only)", "tags": [ "users", ], @@ -373,6 +547,63 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A ], "type": "object", }, + "ProfileResponseDto": { + "properties": { + "avatarUrl": { + "description": "URL to user avatar image", + "type": "string", + }, + "bio": { + "description": "User bio/description", + "type": "string", + }, + "createdAt": { + "description": "When the user account was created", + "format": "date-time", + "type": "string", + }, + "displayName": { + "description": "Display name shown in the UI", + "type": "string", + }, + "email": { + "description": "User email address", + "type": "string", + }, + "firstName": { + "description": "First name", + "type": "string", + }, + "id": { + "description": "User ID", + "type": "string", + }, + "lastName": { + "description": "Last name", + "type": "string", + }, + "preferences": { + "description": "User notification/currency preferences", + "type": "object", + }, + "stellarPublicKey": { + "description": "Primary linked Stellar public key", + "type": "string", + }, + "updatedAt": { + "description": "When the user account was last updated", + "format": "date-time", + "type": "string", + }, + }, + "required": [ + "id", + "email", + "createdAt", + "updatedAt", + ], + "type": "object", + }, "StellarAccountResponseDto": { "properties": { "createdAt": { @@ -496,8 +727,83 @@ exports[`Users API Schema Snapshot should match the OpenAPI snapshot for Users A ], "type": "object", }, - "User": { - "properties": {}, + "UserAdminResponseDto": { + "properties": { + "avatarUrl": { + "description": "URL to user avatar image", + "nullable": true, + "type": "object", + }, + "bio": { + "description": "User bio/description", + "nullable": true, + "type": "object", + }, + "createdAt": { + "description": "When the user account was created", + "format": "date-time", + "type": "string", + }, + "displayName": { + "description": "Display name shown in the UI", + "nullable": true, + "type": "object", + }, + "email": { + "description": "User email address", + "nullable": true, + "type": "object", + }, + "firstName": { + "description": "First name", + "nullable": true, + "type": "object", + }, + "id": { + "description": "User ID", + "type": "string", + }, + "lastName": { + "description": "Last name", + "nullable": true, + "type": "object", + }, + "preferences": { + "description": "User notification/currency preferences", + "type": "object", + }, + "role": { + "description": "User role", + "enum": [ + "user", + "reviewer", + "admin", + ], + "type": "string", + }, + "stellarPublicKey": { + "description": "Primary linked Stellar public key", + "nullable": true, + "type": "object", + }, + "twoFactorEnabled": { + "description": "Whether two-factor authentication is enabled", + "type": "boolean", + }, + "updatedAt": { + "description": "When the user account was last updated", + "format": "date-time", + "type": "string", + }, + }, + "required": [ + "id", + "role", + "preferences", + "twoFactorEnabled", + "createdAt", + "updatedAt", + ], "type": "object", }, } diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 01a94404f..aac9dace6 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -293,7 +293,7 @@ export class UsersController { @ApiResponse({ status: 401, description: 'Unauthorized' }) @UseInterceptors(FileInterceptor('avatar')) async uploadAvatar( - @Param('id') accountId: string, + @Req() req: RequestWithUser, @UploadedFile( new ParseFilePipe({ validators: [ @@ -307,7 +307,7 @@ export class UsersController { ) file: Buffer, ) { - return await this.usersService.updateUserProfilePicture(file, accountId); + return await this.usersService.updateUserProfilePicture(file, req.user.id); } @Post('me/accounts/:id/primary') From 336f01a5d6e02e97e16ca874fa9498f833d85ee8 Mon Sep 17 00:00:00 2001 From: datagirl Date: Fri, 28 Aug 2026 19:55:17 +0100 Subject: [PATCH 4/6] Update config.ts --- apps/backend/src/lib/config.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index 52076012c..d5d4fb1f1 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -141,6 +141,24 @@ const splitCsv = (value: string | undefined): string[] => { .filter(Boolean); }; +/** + * Parse a port-like numeric environment variable. + * + * `z.coerce.number()` turns an unset/empty variable into `Number(undefined)` or + * `Number('')`, which yields `NaN` and fails validation with a cryptic + * "Expected number, received nan". That broke `migration:run` in CI, where + * `PORT` is intentionally left unset (it is irrelevant to migrations). + * + * Treat unset/empty as the provided fallback so the app boots, while still + * rejecting values that are present but genuinely non-numeric/out of range. + */ +const portField = (fallback: number) => + z.preprocess( + (value) => + value === undefined || value === null || value === '' ? fallback : value, + z.coerce.number().int().min(1).max(65535), + ); + const isNodeEnvironment = (value: string): value is NodeEnvironment => value === 'development' || value === 'test' || @@ -311,10 +329,10 @@ const envSchema = z NODE_ENV: z.enum(['development', 'test', 'staging', 'production']), ENVIRONMENT: z.string().min(1).default(runtime.environment), - PORT: z.coerce.number().int().min(1).max(65535), + PORT: portField(3000), DB_HOST: z.string().min(1), - DB_PORT: z.coerce.number().int().min(1).max(65535), + DB_PORT: portField(5432), DB_USERNAME: z.string().min(1), DB_PASSWORD: z.string().min(1), // SECRET — never log DB_DATABASE: z.string().min(1), From b14667ad9ea2155d9b183f3d793d3cb8cc6e3c90 Mon Sep 17 00:00:00 2001 From: datagirl Date: Fri, 28 Aug 2026 20:07:15 +0100 Subject: [PATCH 5/6] remove stale merge conflict markers --- apps/backend/src/lib/config.ts | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index d5d4fb1f1..52076012c 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -141,24 +141,6 @@ const splitCsv = (value: string | undefined): string[] => { .filter(Boolean); }; -/** - * Parse a port-like numeric environment variable. - * - * `z.coerce.number()` turns an unset/empty variable into `Number(undefined)` or - * `Number('')`, which yields `NaN` and fails validation with a cryptic - * "Expected number, received nan". That broke `migration:run` in CI, where - * `PORT` is intentionally left unset (it is irrelevant to migrations). - * - * Treat unset/empty as the provided fallback so the app boots, while still - * rejecting values that are present but genuinely non-numeric/out of range. - */ -const portField = (fallback: number) => - z.preprocess( - (value) => - value === undefined || value === null || value === '' ? fallback : value, - z.coerce.number().int().min(1).max(65535), - ); - const isNodeEnvironment = (value: string): value is NodeEnvironment => value === 'development' || value === 'test' || @@ -329,10 +311,10 @@ const envSchema = z NODE_ENV: z.enum(['development', 'test', 'staging', 'production']), ENVIRONMENT: z.string().min(1).default(runtime.environment), - PORT: portField(3000), + PORT: z.coerce.number().int().min(1).max(65535), DB_HOST: z.string().min(1), - DB_PORT: portField(5432), + DB_PORT: z.coerce.number().int().min(1).max(65535), DB_USERNAME: z.string().min(1), DB_PASSWORD: z.string().min(1), // SECRET — never log DB_DATABASE: z.string().min(1), From 21a35e87a2f3c8c37f935ebc622dfcaef52b8feb Mon Sep 17 00:00:00 2001 From: datagirl Date: Fri, 28 Aug 2026 20:58:38 +0100 Subject: [PATCH 6/6] fix(backend): resolve backend-checks --- .github/workflows/backend.yml | 1 + apps/backend/src/database/data-source.ts | 12 ++++----- apps/backend/src/database/db-env.ts | 34 ++++++++++++++++++++++++ apps/backend/tsconfig.json | 3 +-- 4 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 apps/backend/src/database/db-env.ts diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 8b392dac0..c5851a109 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -48,6 +48,7 @@ jobs: env: NODE_ENV: test + PORT: '3000' DB_HOST: localhost DB_PORT: '5432' DB_USERNAME: postgres diff --git a/apps/backend/src/database/data-source.ts b/apps/backend/src/database/data-source.ts index 041b3cd40..8735b3223 100644 --- a/apps/backend/src/database/data-source.ts +++ b/apps/backend/src/database/data-source.ts @@ -1,14 +1,14 @@ import { DataSource } from 'typeorm'; -import { config } from '../lib/config'; +import { dbConfig } from './db-env'; export default new DataSource({ type: 'postgres', - host: config.database.host, - port: config.database.port, - username: config.database.username, + host: dbConfig.host, + port: dbConfig.port, + username: dbConfig.username, // TypeORM expects a plain credential string for the connection handshake. - password: config.database.password.reveal(), - database: config.database.database, + password: dbConfig.password, + database: dbConfig.database, entities: ['dist/**/*.entity.js', 'src/**/*.entity.ts'], diff --git a/apps/backend/src/database/db-env.ts b/apps/backend/src/database/db-env.ts new file mode 100644 index 000000000..31492781f --- /dev/null +++ b/apps/backend/src/database/db-env.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +const dbEnvSchema = z.object({ + DB_HOST: z.string().min(1), + DB_PORT: z.coerce.number().int().min(1).max(65535), + DB_USERNAME: z.string().min(1), + DB_PASSWORD: z.string().min(1), + DB_DATABASE: z.string().min(1), +}); + +const result = dbEnvSchema.safeParse(process.env); + +if (!result.success) { + const details = result.error.issues + .map((issue) => { + const variable = issue.path.length > 0 ? issue.path.join('.') : 'ENVIRONMENT'; + return `${variable}: ${issue.message}`; + }) + .join('\n'); + + throw new Error( + `Database configuration validation failed. Fix the following variables:\n${details}`, + ); +} + +export const dbConfig = Object.freeze({ + host: result.data.DB_HOST, + port: result.data.DB_PORT, + username: result.data.DB_USERNAME, + password: result.data.DB_PASSWORD, + database: result.data.DB_DATABASE, +}); + +export type DbConfig = typeof dbConfig; diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index d6e1d00b6..c424ecf26 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -13,10 +13,9 @@ "target": "ES2022", "sourceMap": true, "outDir": "./dist", - "baseUrl": "./", "incremental": true, "skipLibCheck": true, - "ignoreDeprecations": "5.0", + "ignoreDeprecations": "6.0", "strictNullChecks": true, "forceConsistentCasingInFileNames": true, "noImplicitAny": false,