Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CorrelationIdInterceptor } from './common/interceptors/correlation-id.i
import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor';
import { RequestLoggingInterceptor } from './common/interceptors/request-logging.interceptor';
import { GracefulShutdownInterceptor } from './common/interceptors/graceful-shutdown.interceptor';
import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor';
import { TieredThrottlerGuard } from './common/guards/tiered-throttler.guard';
import { CommonModule } from './common/common.module';
import { EventEmitterModule } from '@nestjs/event-emitter';
Expand Down Expand Up @@ -374,6 +375,10 @@ const envValidationSchema = Joi.object({
provide: APP_INTERCEPTOR,
useClass: GracefulShutdownInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: IdempotencyInterceptor,
},
],
})
export class AppModule implements NestModule {
Expand Down
10 changes: 10 additions & 0 deletions backend/src/common/decorators/idempotent.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { SetMetadata } from '@nestjs/common';

export const IDEMPOTENCY_KEY = 'idempotency';

export interface IdempotencyOptions {
ttlSeconds?: number;
}

export const Idempotent = (options?: IdempotencyOptions) =>
SetMetadata(IDEMPOTENCY_KEY, { ttlSeconds: options?.ttlSeconds ?? 86400 });
77 changes: 28 additions & 49 deletions backend/src/common/dto/api-error-response.dto.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { StandardErrorResponseDto } from './standard-error-response.dto';
import { ErrorCode } from '../enums/error-code.enum';

export class ApiErrorResponseDto extends StandardErrorResponseDto {
@ApiProperty({
example: 'Bad Request',
description: 'Error type (deprecated, use errorCode instead)',
required: false,
enum: ErrorCode,
example: ErrorCode.BAD_REQUEST,
description: 'Stable, machine-readable error code',
})
error?: string;
declare errorCode: ErrorCode;
}

export class ValidationErrorDto extends ApiErrorResponseDto {
@ApiProperty({ example: 422 })
statusCode = 422;

@ApiProperty({ enum: [ErrorCode.VALIDATION_ERROR] })
errorCode = ErrorCode.VALIDATION_ERROR;

@ApiProperty({
example: [
{
Expand All @@ -21,7 +28,7 @@ export class ValidationErrorDto extends ApiErrorResponseDto {
},
},
],
description: 'Validation errors',
description: 'Per-field validation errors',
})
declare errors?: Array<{
field: string;
Expand All @@ -31,61 +38,33 @@ export class ValidationErrorDto extends ApiErrorResponseDto {
}

export class UnauthorizedErrorDto extends ApiErrorResponseDto {
@ApiProperty({
example: 401,
description: 'HTTP status code',
})
@ApiProperty({ example: 401 })
statusCode = 401;

@ApiProperty({
example: 'AUTH_001',
description: 'Error code',
})
errorCode = 'AUTH_001';

@ApiProperty({
example: 'Authentication required. Please provide valid credentials.',
description: 'Error message',
})
message = 'Authentication required. Please provide valid credentials.';
@ApiProperty({ enum: [ErrorCode.UNAUTHORIZED] })
errorCode = ErrorCode.UNAUTHORIZED;
}

export class ForbiddenErrorDto extends ApiErrorResponseDto {
@ApiProperty({
example: 403,
description: 'HTTP status code',
})
@ApiProperty({ example: 403 })
statusCode = 403;

@ApiProperty({
example: 'AUTHZ_001',
description: 'Error code',
})
errorCode = 'AUTHZ_001';

@ApiProperty({
example: 'You do not have permission to access this resource.',
description: 'Error message',
})
message = 'You do not have permission to access this resource.';
@ApiProperty({ enum: [ErrorCode.FORBIDDEN] })
errorCode = ErrorCode.FORBIDDEN;
}

export class NotFoundErrorDto extends ApiErrorResponseDto {
@ApiProperty({
example: 404,
description: 'HTTP status code',
})
@ApiProperty({ example: 404 })
statusCode = 404;

@ApiProperty({
example: 'SYS_404',
description: 'Error code',
})
errorCode = 'SYS_404';
@ApiProperty({ enum: [ErrorCode.NOT_FOUND] })
errorCode = ErrorCode.NOT_FOUND;
}

@ApiProperty({
example: 'The requested resource was not found.',
description: 'Error message',
})
message = 'The requested resource was not found.';
export class ConflictErrorDto extends ApiErrorResponseDto {
@ApiProperty({ example: 409 })
statusCode = 409;

@ApiProperty({ enum: [ErrorCode.CONFLICT] })
errorCode = ErrorCode.CONFLICT;
}
63 changes: 63 additions & 0 deletions backend/src/common/enums/error-code.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
export enum ErrorCode {
// Generic
INTERNAL_ERROR = 'INTERNAL_ERROR',
VALIDATION_ERROR = 'VALIDATION_ERROR',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
NOT_FOUND = 'NOT_FOUND',
CONFLICT = 'CONFLICT',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
BAD_REQUEST = 'BAD_REQUEST',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',

// Auth
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
SESSION_EXPIRED = 'SESSION_EXPIRED',

// Savings
SAVINGS_PRODUCT_NOT_FOUND = 'SAVINGS_PRODUCT_NOT_FOUND',
SAVINGS_GOAL_NOT_FOUND = 'SAVINGS_GOAL_NOT_FOUND',
SUBSCRIPTION_NOT_FOUND = 'SUBSCRIPTION_NOT_FOUND',
INSUFFICIENT_BALANCE = 'INSUFFICIENT_BALANCE',
WITHDRAWAL_NOT_FOUND = 'WITHDRAWAL_NOT_FOUND',
DEPOSIT_FAILED = 'DEPOSIT_FAILED',
WITHDRAWAL_FAILED = 'WITHDRAWAL_FAILED',
MINIMUM_AMOUNT_NOT_MET = 'MINIMUM_AMOUNT_NOT_MET',

// Governance
PROPOSAL_NOT_FOUND = 'PROPOSAL_NOT_FOUND',
INVALID_GOVERNANCE_STATE = 'INVALID_GOVERNANCE_STATE',
ALREADY_VOTED = 'ALREADY_VOTED',
NO_VOTING_POWER = 'NO_VOTING_POWER',
PROPOSAL_NOT_ACTIVE = 'PROPOSAL_NOT_ACTIVE',

// Transactions
TRANSACTION_NOT_FOUND = 'TRANSACTION_NOT_FOUND',
DUPLICATE_TRANSACTION = 'DUPLICATE_TRANSACTION',
INVALID_TRANSACTION_STATE = 'INVALID_TRANSACTION_STATE',

// Disputes
DISPUTE_NOT_FOUND = 'DISPUTE_NOT_FOUND',
INVALID_DISPUTE_STATE = 'INVALID_DISPUTE_STATE',

// Claims
CLAIM_NOT_FOUND = 'CLAIM_NOT_FOUND',
INVALID_CLAIM_STATE = 'INVALID_CLAIM_STATE',

// Referrals
REFERRAL_NOT_FOUND = 'REFERRAL_NOT_FOUND',
SELF_REFERRAL = 'SELF_REFERRAL',
REFERRAL_ALREADY_USED = 'REFERRAL_ALREADY_USED',

// Blockchain / RPC
SOROBAN_RPC_TIMEOUT = 'SOROBAN_RPC_TIMEOUT',
SOROBAN_RPC_EXHAUSTED = 'SOROBAN_RPC_EXHAUSTED',
BLOCKCHAIN_ERROR = 'BLOCKCHAIN_ERROR',

// Database
DB_CONNECTION_ERROR = 'DB_CONNECTION_ERROR',

// Idempotency
IDEMPOTENCY_CONFLICT = 'IDEMPOTENCY_CONFLICT',
}
66 changes: 66 additions & 0 deletions backend/src/common/exceptions/domain.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { ErrorCode } from '../enums/error-code.enum';

export interface DomainExceptionOptions {
errorCode: ErrorCode;
message: string;
statusCode?: HttpStatus;
details?: Record<string, unknown> | Array<Record<string, unknown>>;
docsUrl?: string;
}

export class DomainException extends HttpException {
public readonly errorCode: ErrorCode;
public readonly details?:
| Record<string, unknown>
| Array<Record<string, unknown>>;
public readonly docsUrl?: string;

constructor(options: DomainExceptionOptions) {
const status = options.statusCode ?? HttpStatus.BAD_REQUEST;
super(
{
errorCode: options.errorCode,
message: options.message,
details: options.details,
docsUrl: options.docsUrl,
},
status,
);
this.errorCode = options.errorCode;
this.details = options.details;
this.docsUrl = options.docsUrl;
}
}

export class InsufficientFundsException extends DomainException {
constructor(details?: Record<string, unknown>) {
super({
errorCode: ErrorCode.INSUFFICIENT_BALANCE,
message: 'Insufficient balance to complete this operation',
statusCode: HttpStatus.UNPROCESSABLE_ENTITY,
details,
});
}
}

export class InvalidGovernanceStateException extends DomainException {
constructor(message: string, details?: Record<string, unknown>) {
super({
errorCode: ErrorCode.INVALID_GOVERNANCE_STATE,
message,
statusCode: HttpStatus.CONFLICT,
details,
});
}
}

export class ResourceNotFoundException extends DomainException {
constructor(resource: string, id?: string) {
super({
errorCode: ErrorCode.NOT_FOUND,
message: id ? `${resource} '${id}' not found` : `${resource} not found`,
statusCode: HttpStatus.NOT_FOUND,
});
}
}
Loading
Loading