diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 897da0ac..fdd14b03 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -53,6 +53,8 @@ import { ExportModule } from './export/export.module'; import { SignalsModule } from './signals/signals.module'; import { AppConfigModule } from './config/config.module'; import { CrowdfundModule } from './crowdfund/crowdfund.module'; +import { AuditModule } from './audit/audit.module'; +import { AuditLogInterceptor } from './audit/interceptors/audit-log.interceptor'; @Module({ imports: [ @@ -124,6 +126,7 @@ import { CrowdfundModule } from './crowdfund/crowdfund.module'; FeatureFlagsModule, CrowdfundModule, AppConfigModule, + AuditModule, ], controllers: [AppController, TestController, TestExceptionController], providers: [ @@ -136,6 +139,10 @@ import { CrowdfundModule } from './crowdfund/crowdfund.module'; provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor, }, + { + provide: APP_INTERCEPTOR, + useClass: AuditLogInterceptor, + }, { provide: APP_INTERCEPTOR, useClass: DeprecationInterceptor, @@ -146,4 +153,4 @@ export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer.apply(RequestIdMiddleware, LoggerMiddleware).forRoutes('*'); } -} \ No newline at end of file +} diff --git a/apps/backend/src/audit/audit.controller.ts b/apps/backend/src/audit/audit.controller.ts new file mode 100644 index 00000000..f8ea49e7 --- /dev/null +++ b/apps/backend/src/audit/audit.controller.ts @@ -0,0 +1,69 @@ +import { + Controller, + Get, + Query, + UseGuards, + ParseIntPipe, + DefaultValuePipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { AuditService } from './audit.service'; +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'; + +@ApiTags('admin-audit-logs') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +@Controller('admin/audit-logs') +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get() + @ApiOperation({ + summary: 'Get all audit logs (admin only)', + description: + 'Retrieves a paginated list of audit logs. Requires admin privileges.', + }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'offset', required: false, type: Number }) + @ApiResponse({ + status: 200, + description: 'Audit logs retrieved successfully', + schema: { + properties: { + logs: { + type: 'array', + items: { + properties: { + id: { type: 'string', format: 'uuid' }, + userId: { type: 'string', format: 'uuid', nullable: true }, + action: { type: 'string', example: 'login' }, + ipAddress: { type: 'string', example: '127.0.0.1' }, + metadata: { type: 'object', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + }, + count: { type: 'number', example: 1 }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) + async getAuditLogs( + @Query('limit', new DefaultValuePipe(100), ParseIntPipe) limit: number, + @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number, + ) { + const [logs, count] = await this.auditService.findAll(limit, offset); + return { logs, count }; + } +} diff --git a/apps/backend/src/audit/audit.module.ts b/apps/backend/src/audit/audit.module.ts new file mode 100644 index 00000000..be4b310f --- /dev/null +++ b/apps/backend/src/audit/audit.module.ts @@ -0,0 +1,18 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditLog } from './entities/audit-log.entity'; +import { AuditService } from './audit.service'; +import { AuditController } from './audit.controller'; +import { AuditLogInterceptor } from './interceptors/audit-log.interceptor'; +import { UsersModule } from '../users/users.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([AuditLog]), + forwardRef(() => UsersModule), + ], + controllers: [AuditController], + providers: [AuditService, AuditLogInterceptor], + exports: [AuditService, AuditLogInterceptor], +}) +export class AuditModule {} diff --git a/apps/backend/src/audit/audit.service.ts b/apps/backend/src/audit/audit.service.ts new file mode 100644 index 00000000..6ca1504c --- /dev/null +++ b/apps/backend/src/audit/audit.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditLog } from './entities/audit-log.entity'; + +@Injectable() +export class AuditService { + constructor( + @InjectRepository(AuditLog) + private readonly auditLogRepo: Repository, + ) {} + + async log( + action: string, + userId: string | null, + ipAddress: string | null, + metadata?: Record, + ): Promise { + const auditLog = this.auditLogRepo.create({ + action, + userId, + ipAddress, + metadata: metadata || null, + }); + return this.auditLogRepo.save(auditLog); + } + + async findAll(limit = 100, offset = 0): Promise<[AuditLog[], number]> { + return this.auditLogRepo.findAndCount({ + order: { createdAt: 'DESC' }, + take: limit, + skip: offset, + }); + } +} diff --git a/apps/backend/src/audit/decorators/audit-log.decorator.ts b/apps/backend/src/audit/decorators/audit-log.decorator.ts new file mode 100644 index 00000000..7b6218f1 --- /dev/null +++ b/apps/backend/src/audit/decorators/audit-log.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const AUDIT_LOG_ACTION_KEY = 'audit_log_action'; +export const AuditLogAction = (action: string) => + SetMetadata(AUDIT_LOG_ACTION_KEY, action); diff --git a/apps/backend/src/audit/entities/audit-log.entity.ts b/apps/backend/src/audit/entities/audit-log.entity.ts new file mode 100644 index 00000000..461516f4 --- /dev/null +++ b/apps/backend/src/audit/entities/audit-log.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('audit_logs') +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'userId', type: 'uuid', nullable: true }) + @Index() + userId: string | null; + + @Column({ type: 'varchar', length: 100 }) + @Index() + action: string; + + @Column({ type: 'varchar', length: 45, nullable: true }) + ipAddress: string | null; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + @Index() + createdAt: Date; + + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'userId' }) + user: User | null; +} diff --git a/apps/backend/src/audit/interceptors/audit-log.interceptor.ts b/apps/backend/src/audit/interceptors/audit-log.interceptor.ts new file mode 100644 index 00000000..98c3c6ac --- /dev/null +++ b/apps/backend/src/audit/interceptors/audit-log.interceptor.ts @@ -0,0 +1,110 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { AuditService } from '../audit.service'; +import { AUDIT_LOG_ACTION_KEY } from '../decorators/audit-log.decorator'; +import { UsersService } from '../../users/users.service'; + +interface RequestWithUser { + headers: Record; + ip?: string; + connection?: { remoteAddress?: string }; + body?: Record; + user?: { id?: string; sub?: string }; +} + +@Injectable() +export class AuditLogInterceptor implements NestInterceptor { + constructor( + private readonly reflector: Reflector, + private readonly auditService: AuditService, + private readonly usersService: UsersService, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const action = this.reflector.get( + AUDIT_LOG_ACTION_KEY, + context.getHandler(), + ); + + if (!action) { + return next.handle(); + } + + const request = context.switchToHttp().getRequest(); + + let ipAddress: string | null = null; + const xForwardedFor = request.headers['x-forwarded-for']; + if (typeof xForwardedFor === 'string') { + ipAddress = xForwardedFor.split(',')[0].trim(); + } else if (Array.isArray(xForwardedFor) && xForwardedFor.length > 0) { + ipAddress = xForwardedFor[0].split(',')[0].trim(); + } else { + ipAddress = request.ip || request.connection?.remoteAddress || null; + } + + // Filter out sensitive data from request body metadata + const metadata = request.body ? { ...request.body } : {}; + const sensitiveKeys = [ + 'password', + 'newPassword', + 'token', + 'signedChallenge', + ]; + for (const key of sensitiveKeys) { + if (key in metadata) { + metadata[key] = '[REDACTED]'; + } + } + + return next.handle().pipe( + tap((response: unknown) => { + // Execute logging asynchronously as fire-and-forget + void (async () => { + let userId: string | null = null; + + // 1. Check if user is already authenticated + if (request.user?.id) { + userId = request.user.id; + } else if (request.user?.sub) { + userId = request.user.sub; + } + + // 2. If login or password reset, resolve user by email from request body + if ( + !userId && + request.body && + typeof request.body.email === 'string' + ) { + try { + const user = await this.usersService.findByEmail( + request.body.email, + ); + if (user) { + userId = user.id; + } + } catch { + // Ignore error + } + } + + // 3. If response contains user object + if (!userId && response && typeof response === 'object') { + const resObj = response as { user?: { id?: string } }; + if (resObj.user?.id) { + userId = resObj.user.id; + } + } + + await this.auditService.log(action, userId, ipAddress, metadata); + })(); + }), + ); + } +} diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index 11ca27c9..a72603ee 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -43,6 +43,8 @@ import { } from '@nestjs/swagger'; import { ProfileResponseDto } from '../users/dto/profile-response.dto'; import { getAuthThrottleOverride } from '../common/rate-limit/rate-limit.config'; +import { AuditLogAction } from '../audit/decorators/audit-log.decorator'; + import { ActiveSessionsResponseDto, RevokeSessionResponseDto, @@ -90,6 +92,7 @@ export class AuthController { }, }, }) + @AuditLogAction('login') async login(@Body() body: LoginDto) { const user = await this.authService.validateUser(body.email, body.password); if (!user) { @@ -172,6 +175,7 @@ export class AuthController { status: 400, description: 'Invalid, expired, or already-used token', }) + @AuditLogAction('password_change') async resetPassword(@Body() body: ResetPasswordDto) { return this.authService.resetPassword(body.token, body.newPassword); } diff --git a/apps/backend/src/common/decorators/deprecated.decorator.ts b/apps/backend/src/common/decorators/deprecated.decorator.ts index e5ac7ad3..1914d512 100644 --- a/apps/backend/src/common/decorators/deprecated.decorator.ts +++ b/apps/backend/src/common/decorators/deprecated.decorator.ts @@ -35,4 +35,4 @@ export function Deprecated(options: DeprecationOptions) { required: false, }), ); -} \ No newline at end of file +} diff --git a/apps/backend/src/common/interceptors/deprecation.interceptor.ts b/apps/backend/src/common/interceptors/deprecation.interceptor.ts index 7155d1a8..35f09eba 100644 --- a/apps/backend/src/common/interceptors/deprecation.interceptor.ts +++ b/apps/backend/src/common/interceptors/deprecation.interceptor.ts @@ -46,7 +46,10 @@ export class DeprecationInterceptor implements NestInterceptor { } if (meta.replacement) { - response.setHeader('Link', `<${meta.replacement}>; rel="successor-version"`); + response.setHeader( + 'Link', + `<${meta.replacement}>; rel="successor-version"`, + ); } this.logger.warn( @@ -58,4 +61,4 @@ export class DeprecationInterceptor implements NestInterceptor { return next.handle().pipe(tap(() => {})); } -} \ No newline at end of file +} diff --git a/apps/backend/src/crowdfund/crowdfund.controller.ts b/apps/backend/src/crowdfund/crowdfund.controller.ts index d150e9e7..2de18a68 100644 --- a/apps/backend/src/crowdfund/crowdfund.controller.ts +++ b/apps/backend/src/crowdfund/crowdfund.controller.ts @@ -8,10 +8,23 @@ import { Query, UseGuards, } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; import { CrowdfundService } from './crowdfund.service'; -import { ContributeDto, CreateProjectDto } from './dto/crowdfund.dto'; +import { + ContributeDto, + CreateProjectDto, + CrowdfundProjectDto, + ContributorDto, + ContributionResponseDto, +} from './dto/crowdfund.dto'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +@ApiTags('crowdfund') @Controller('crowdfund') export class CrowdfundController { constructor(private readonly svc: CrowdfundService) {} @@ -19,17 +32,48 @@ export class CrowdfundController { // ── Projects ─────────────────────────────────────────────────────────────── @Get('projects') + @ApiOperation({ + summary: 'List all crowdfund projects', + description: 'Retrieves a list of all active and inactive projects.', + }) + @ApiResponse({ + status: 200, + description: 'List of projects retrieved successfully', + type: [CrowdfundProjectDto], + }) listProjects() { return this.svc.listProjects(); } @Get('projects/:id') + @ApiOperation({ + summary: 'Get project details', + description: + 'Retrieves detailed information of a single project by its ID.', + }) + @ApiResponse({ + status: 200, + description: 'Project details retrieved successfully', + type: CrowdfundProjectDto, + }) + @ApiResponse({ status: 404, description: 'Project not found' }) getProject(@Param('id', ParseIntPipe) id: number) { return this.svc.getProject(id); } @Post('projects') @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Create a new project', + description: 'Creates a project listing. Requires authentication.', + }) + @ApiResponse({ + status: 201, + description: 'Project created successfully', + type: CrowdfundProjectDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) createProject(@Body() dto: CreateProjectDto) { return this.svc.createProject(dto); } @@ -37,22 +81,71 @@ export class CrowdfundController { // ── Contributions ────────────────────────────────────────────────────────── @Post('contribute') + @ApiOperation({ + summary: 'Contribute to a project', + description: 'Submit a contribution transaction to support a project.', + }) + @ApiResponse({ + status: 200, + description: 'Contribution processed successfully', + type: ContributionResponseDto, + }) contribute(@Body() dto: ContributeDto) { return this.svc.contribute(dto); } @Get('projects/:id/contributors') + @ApiOperation({ + summary: 'Get project contributors', + description: + 'Retrieve a list of contributors and their total contributions for a project.', + }) + @ApiResponse({ + status: 200, + description: 'Contributors list retrieved successfully', + type: [ContributorDto], + }) + @ApiResponse({ status: 404, description: 'Project not found' }) getContributors(@Param('id', ParseIntPipe) id: number) { return this.svc.getContributors(id); } @Get('projects/:id/balance') + @ApiOperation({ + summary: 'Get project balance info', + description: + 'Retrieve details about the current deposits, withdrawals, and balance.', + }) + @ApiResponse({ + status: 200, + description: 'Balance info retrieved successfully', + schema: { + properties: { + totalDeposited: { type: 'string', example: '15000' }, + totalWithdrawn: { type: 'string', example: '0' }, + balance: { type: 'string', example: '15000' }, + }, + }, + }) + @ApiResponse({ status: 404, description: 'Project not found' }) getBalance(@Param('id', ParseIntPipe) id: number) { return this.svc.getProjectBalance(id); } @Get('projects/:id/my-contributions') @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Get my contributions to a project', + description: + 'Retrieve all contributions made by a specific public key belonging to the user.', + }) + @ApiResponse({ + status: 200, + description: 'Contributions list retrieved successfully', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Project not found' }) getMyContributions( @Param('id', ParseIntPipe) id: number, @Query('publicKey') publicKey: string, diff --git a/apps/backend/src/crowdfund/dto/crowdfund.dto.ts b/apps/backend/src/crowdfund/dto/crowdfund.dto.ts index 03afd786..1ce3e275 100644 --- a/apps/backend/src/crowdfund/dto/crowdfund.dto.ts +++ b/apps/backend/src/crowdfund/dto/crowdfund.dto.ts @@ -8,6 +8,7 @@ import { ValidateNested, } from 'class-validator'; import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; // ── Enums ──────────────────────────────────────────────────────────────────── @@ -22,41 +23,85 @@ export enum OnChainStatus { // ── Request DTOs ───────────────────────────────────────────────────────────── export class CreateRoadmapItemDto { + @ApiProperty({ + description: 'Title of the roadmap milestone', + example: 'Soroban Contract Audit', + }) @IsString() title: string; + @ApiProperty({ + description: 'Detailed description of the milestone', + example: 'Independent security audit of the smart contracts.', + }) @IsString() description: string; + @ApiProperty({ + description: 'Target date of completion (ISO or format string)', + example: '2026-12-31', + }) @IsString() targetDate: string; } export class CreateProjectDto { + @ApiProperty({ + description: 'Stellar public key of the project owner', + example: 'G...OWNER', + }) @IsString() owner: string; + @ApiProperty({ + description: 'Name of the project', + example: 'BridgeWise Ingestion Hardening', + }) @IsString() name: string; + @ApiPropertyOptional({ + description: 'Detailed description of the project', + example: 'Hardening the ingestion layer against transient failures.', + }) @IsOptional() @IsString() description?: string; + @ApiPropertyOptional({ + description: 'URL of the project banner image', + example: 'https://example.com/banner.png', + }) @IsOptional() @IsString() bannerUrl?: string; + @ApiProperty({ + description: 'Target funding amount in stroops or decimal string', + example: '50000', + }) @IsString() targetAmount: string; + @ApiProperty({ + description: 'Stellar token asset address used for funding', + example: 'CDLZEA4RTA3AOF7UBNTQHFRJ67676767676767676767676767676767', + }) @IsString() tokenAddress: string; + @ApiPropertyOptional({ + description: 'Stellar smart contract address of the crowdfund vault', + example: 'CC...VAULT', + }) @IsOptional() @IsString() contractAddress?: string; + @ApiPropertyOptional({ + description: 'Roadmap milestones for the project', + type: [CreateRoadmapItemDto], + }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @@ -65,14 +110,26 @@ export class CreateProjectDto { } export class ContributeDto { + @ApiProperty({ + description: 'ID of the project to contribute to', + example: 1, + }) @IsNumber() @IsInt() @Min(1) projectId: number; + @ApiProperty({ + description: 'Amount to contribute as a string', + example: '1000', + }) @IsString() amount: string; + @ApiProperty({ + description: 'Stellar public key of the contributor', + example: 'G...SENDER', + }) @IsString() senderPublicKey: string; } @@ -80,50 +137,189 @@ export class ContributeDto { // ── Response shapes ────────────────────────────────────────────────────────── export class RoadmapItemDto { + @ApiProperty({ description: 'Milestone ID', example: 'rm_123' }) id: string; + + @ApiProperty({ + description: 'Title of the roadmap milestone', + example: 'Soroban Contract Audit', + }) title: string; + + @ApiProperty({ + description: 'Detailed description of the milestone', + example: 'Independent security audit of the smart contracts.', + }) description: string; + + @ApiProperty({ + description: 'Target date of completion', + example: '2026-12-31', + }) targetDate: string; + + @ApiProperty({ description: 'Completion status', example: false }) isCompleted: boolean; } export class CrowdfundProjectDto { + @ApiProperty({ description: 'Project ID', example: 1 }) id: number; + + @ApiProperty({ + description: 'Stellar public key of the project owner', + example: 'G...OWNER', + }) owner: string; + + @ApiProperty({ + description: 'Name of the project', + example: 'BridgeWise Ingestion Hardening', + }) name: string; + + @ApiPropertyOptional({ + description: 'Detailed description of the project', + example: 'Hardening the ingestion layer against transient failures.', + }) description?: string; + + @ApiPropertyOptional({ + description: 'URL of the project banner image', + example: 'https://example.com/banner.png', + }) bannerUrl?: string; + + @ApiProperty({ description: 'Target funding amount', example: '50000' }) targetAmount: string; + + @ApiProperty({ + description: 'Stellar token asset address used for funding', + example: 'CDLZEA4RTA3AOF7UBNTQHFRJ67676767676767676767676767676767', + }) tokenAddress: string; + + @ApiPropertyOptional({ + description: 'Stellar smart contract address of the crowdfund vault', + example: 'CC...VAULT', + }) contractAddress?: string; + + @ApiProperty({ + description: 'Total deposited/contributed amount', + example: '15000', + }) totalDeposited: string; + + @ApiProperty({ description: 'Total withdrawn amount', example: '0' }) totalWithdrawn: string; + + @ApiProperty({ + description: 'Whether the project is currently active', + example: true, + }) isActive: boolean; + + @ApiProperty({ + description: 'On-chain project status', + enum: OnChainStatus, + example: OnChainStatus.ACTIVE, + }) onChainStatus: OnChainStatus; + + @ApiProperty({ + description: 'Timestamp of the last sync with the ledger', + example: '2026-05-27T20:58:35Z', + }) lastSyncedAt: string; + + @ApiProperty({ description: 'Number of unique contributors', example: 12 }) contributorCount: number; + + @ApiProperty({ + description: 'Roadmap milestones list', + type: [RoadmapItemDto], + }) roadmap: RoadmapItemDto[]; + + @ApiProperty({ + description: 'Timestamp of project creation', + example: '2026-05-27T20:58:35Z', + }) createdAt: string; } export class ContributorDto { + @ApiProperty({ + description: 'Stellar public key of the contributor', + example: 'G...CONTRIBUTOR', + }) publicKey: string; + + @ApiProperty({ + description: 'Total contributed amount across all transactions', + example: '5000', + }) totalContributed: string; + + @ApiProperty({ description: 'Number of contributions made', example: 3 }) contributionCount: number; + + @ApiProperty({ + description: 'Timestamp of the last contribution', + example: '2026-05-27T20:58:35Z', + }) lastContributionAt: string; } export class ContributionResponseDto { + @ApiProperty({ + description: 'Hash of the transaction submitted to the Stellar network', + example: 'a1b2c3d4...', + }) transactionHash: string; + + @ApiProperty({ + description: 'Status of the contribution on ledger', + example: 'SUCCESS', + }) status: 'SUCCESS' | 'FAILED' | 'PENDING'; + + @ApiPropertyOptional({ + description: 'Stellar ledger sequence number containing the transaction', + example: 123456, + }) ledger?: number; + + @ApiPropertyOptional({ + description: 'Optional feedback or error message', + example: 'Contribution successful', + }) message?: string; } export class ContributionRecordDto { + @ApiProperty({ description: 'Project ID', example: 1 }) projectId: number; + + @ApiProperty({ + description: 'Stellar public key of the contributor', + example: 'G...CONTRIBUTOR', + }) contributor: string; + + @ApiProperty({ description: 'Contributed amount', example: '1000' }) amount: string; + + @ApiProperty({ + description: 'Timestamp of the contribution', + example: '2026-05-27T20:58:35Z', + }) timestamp: string; + + @ApiProperty({ + description: 'Hash of the transaction', + example: 'a1b2c3d4...', + }) transactionHash: string; } diff --git a/apps/backend/src/database/migrations/1773000000000-CreateAuditLogs.ts b/apps/backend/src/database/migrations/1773000000000-CreateAuditLogs.ts new file mode 100644 index 00000000..d4a4f25d --- /dev/null +++ b/apps/backend/src/database/migrations/1773000000000-CreateAuditLogs.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAuditLogs1773000000000 implements MigrationInterface { + name = 'CreateAuditLogs1773000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "audit_logs" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "userId" uuid, + "action" character varying(100) NOT NULL, + "ipAddress" character varying(45), + "metadata" jsonb, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_audit_logs" PRIMARY KEY ("id"), + CONSTRAINT "FK_audit_logs_userId" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION + )`, + ); + + await queryRunner.query( + `CREATE INDEX "IDX_audit_logs_userId" ON "audit_logs" ("userId")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_audit_logs_action" ON "audit_logs" ("action")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_audit_logs_createdAt" ON "audit_logs" ("createdAt")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "audit_logs" DROP CONSTRAINT "FK_audit_logs_userId"`, + ); + await queryRunner.query(`DROP INDEX "IDX_audit_logs_createdAt"`); + await queryRunner.query(`DROP INDEX "IDX_audit_logs_action"`); + await queryRunner.query(`DROP INDEX "IDX_audit_logs_userId"`); + await queryRunner.query(`DROP TABLE "audit_logs"`); + } +} diff --git a/apps/backend/src/feature-flags/dto/feature-flag.dto.ts b/apps/backend/src/feature-flags/dto/feature-flag.dto.ts new file mode 100644 index 00000000..d4016be2 --- /dev/null +++ b/apps/backend/src/feature-flags/dto/feature-flag.dto.ts @@ -0,0 +1,40 @@ +import { IsString, IsBoolean, IsOptional, IsObject } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpsertFeatureFlagDto { + @ApiProperty({ + description: 'Unique feature flag key', + example: 'new-onboarding-flow', + }) + @IsString() + key: string; + + @ApiProperty({ description: 'Whether the feature is enabled', example: true }) + @IsBoolean() + enabled: boolean; + + @ApiPropertyOptional({ + description: 'Optional conditions (e.g. user roles, specific user IDs)', + example: { roles: ['ADMIN'] }, + }) + @IsOptional() + @IsObject() + conditions?: Record; +} + +export class FeatureFlagResponseDto { + @ApiProperty({ + description: 'Unique feature flag key', + example: 'new-onboarding-flow', + }) + key: string; + + @ApiProperty({ description: 'Whether the feature is enabled', example: true }) + enabled: boolean; + + @ApiPropertyOptional({ + description: 'Optional conditions', + example: { roles: ['ADMIN'] }, + }) + conditions?: Record; +} diff --git a/apps/backend/src/feature-flags/feature-flags.controller.ts b/apps/backend/src/feature-flags/feature-flags.controller.ts index 44c3cffa..4385bad1 100644 --- a/apps/backend/src/feature-flags/feature-flags.controller.ts +++ b/apps/backend/src/feature-flags/feature-flags.controller.ts @@ -1,35 +1,78 @@ import { Controller, Get, Param, Post, Body, Delete } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { FeatureFlagsService } from './feature-flags.service'; +import { + UpsertFeatureFlagDto, + FeatureFlagResponseDto, +} from './dto/feature-flag.dto'; +@ApiTags('feature-flags') @Controller('feature-flags') export class FeatureFlagsController { constructor(private readonly flags: FeatureFlagsService) {} @Get() + @ApiOperation({ + summary: 'List all feature flags', + description: + 'Retrieve a list of all defined feature flags and their current status.', + }) + @ApiResponse({ + status: 200, + description: 'List of feature flags retrieved successfully', + type: [FeatureFlagResponseDto], + }) list() { return this.flags.listFlags(); } @Get('check/:key') + @ApiOperation({ + summary: 'Check if a feature is enabled', + description: 'Determine whether a specific feature key is active.', + }) + @ApiResponse({ + status: 200, + description: 'Feature flag status checked successfully', + schema: { + properties: { + key: { type: 'string', example: 'new-onboarding-flow' }, + enabled: { type: 'boolean', example: true }, + }, + }, + }) async check(@Param('key') key: string) { const enabled = await this.flags.isEnabled(key); return { key, enabled }; } @Get(':key') + @ApiOperation({ + summary: 'Get details of a feature flag', + description: 'Retrieves configuration details of a single feature flag.', + }) + @ApiResponse({ + status: 200, + description: 'Feature flag configuration retrieved successfully', + type: FeatureFlagResponseDto, + }) + @ApiResponse({ status: 404, description: 'Feature flag not found' }) get(@Param('key') key: string) { return this.flags.getFlag(key); } @Post() - upsert( - @Body() - body: { - key: string; - enabled: boolean; - conditions?: Record; - }, - ) { + @ApiOperation({ + summary: 'Create or update feature flag configuration', + description: + 'Creates a new feature flag or modifies the active state of an existing one.', + }) + @ApiResponse({ + status: 200, + description: 'Feature flag upserted successfully', + type: FeatureFlagResponseDto, + }) + upsert(@Body() body: UpsertFeatureFlagDto) { return this.flags.upsert( body.key, body.enabled, @@ -38,6 +81,15 @@ export class FeatureFlagsController { } @Delete(':key') + @ApiOperation({ + summary: 'Delete feature flag', + description: 'Removes a feature flag from the system configuration.', + }) + @ApiResponse({ + status: 200, + description: 'Feature flag deleted successfully', + }) + @ApiResponse({ status: 404, description: 'Feature flag not found' }) remove(@Param('key') key: string) { return this.flags.remove(key); } diff --git a/apps/backend/src/grants/dto/grants.dto.ts b/apps/backend/src/grants/dto/grants.dto.ts index c5a36d29..b41b9c98 100644 --- a/apps/backend/src/grants/dto/grants.dto.ts +++ b/apps/backend/src/grants/dto/grants.dto.ts @@ -6,41 +6,69 @@ import { Min, IsArray, } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; export class CreateRoundDto { + @ApiProperty({ + description: 'Name of the grant round', + example: 'Stellar Climate Fund Q2', + }) @IsString() name: string; + @ApiProperty({ + description: 'Stellar token address of the matching pool token', + example: 'CDLZEA4RTA3AOF7UBNTQHFRJ67676767676767676767676767676767', + }) @IsString() tokenAddress: string; + @ApiProperty({ + description: 'Start time of the round (unix timestamp in seconds)', + example: 1774000000, + }) @IsNumber() @IsPositive() - startTime: number; // unix timestamp + startTime: number; + @ApiProperty({ + description: 'End time of the round (unix timestamp in seconds)', + example: 1775000000, + }) @IsNumber() @IsPositive() endTime: number; } export class FundPoolDto { + @ApiProperty({ + description: 'Stellar public key of the funder', + example: 'G...FUNDER', + }) @IsString() funderPublicKey: string; + @ApiProperty({ description: 'ID of the grant round', example: 1 }) @IsNumber() @IsPositive() roundId: number; + @ApiProperty({ + description: 'Amount to fund in stroops/decimal string', + example: '100000', + }) @IsString() amount: string; } export class ApproveProjectDto { + @ApiProperty({ description: 'ID of the grant round', example: 1 }) @IsNumber() @IsInt() @Min(0) roundId: number; + @ApiProperty({ description: 'ID of the project to approve', example: 42 }) @IsNumber() @IsInt() @Min(0) @@ -48,29 +76,41 @@ export class ApproveProjectDto { } export class RecordContributionDto { + @ApiProperty({ description: 'ID of the grant round', example: 1 }) @IsNumber() @IsInt() @Min(0) roundId: number; + @ApiProperty({ description: 'ID of the project contributed to', example: 42 }) @IsNumber() @IsInt() @Min(0) projectId: number; + @ApiProperty({ + description: 'Stellar public key of the contributor', + example: 'G...CONTRIBUTOR', + }) @IsString() contributorPublicKey: string; + @ApiProperty({ description: 'Amount contributed', example: '500' }) @IsString() amount: string; } export class DistributeDto { + @ApiProperty({ description: 'ID of the grant round', example: 1 }) @IsNumber() @IsInt() @Min(0) roundId: number; + @ApiProperty({ + description: 'Array of project owner Stellar public keys', + example: ['G...OWNER1', 'G...OWNER2'], + }) @IsArray() @IsString({ each: true }) projectOwners: string[]; @@ -78,54 +118,175 @@ export class DistributeDto { // ── Response shapes ────────────────────────────────────────────────────────── -export interface RoundDto { +export class RoundDto { + @ApiProperty({ description: 'Round ID', example: 1 }) id: number; + + @ApiProperty({ + description: 'Name of the round', + example: 'Stellar Climate Fund Q2', + }) name: string; + + @ApiProperty({ + description: 'Token asset address', + example: 'CDLZEA4RTA3AOF7UBNTQHFRJ67676767676767676767676767676767', + }) tokenAddress: string; + + @ApiProperty({ description: 'Start time of the round', example: 1774000000 }) startTime: number; + + @ApiProperty({ description: 'End time of the round', example: 1775000000 }) endTime: number; + + @ApiProperty({ + description: 'Total amount in the matching pool', + example: '250000', + }) totalPool: string; + + @ApiProperty({ + description: 'Whether the round is finalized', + example: false, + }) isFinalized: boolean; + + @ApiProperty({ + description: 'Whether the pool has been distributed', + example: false, + }) isDistributed: boolean; + + @ApiProperty({ + description: 'Current status of the round', + example: 'ACTIVE', + }) status: string; } -export interface ProjectQfDto { +export class ProjectQfDto { + @ApiProperty({ description: 'Project ID', example: 42 }) projectId: number; + + @ApiProperty({ + description: 'Quadratic Funding (QF) score', + example: '144.50', + }) qfScore: string; + + @ApiProperty({ description: 'Total contribution amount', example: '12000' }) totalContributions: string; + + @ApiProperty({ + description: 'Total number of unique contributors', + example: 25, + }) contributorCount: number; + + @ApiProperty({ + description: 'Estimated match amount from pool', + example: '3500', + }) estimatedMatch: string; } -export interface ProjectAllocationDto extends ProjectQfDto { +export class ProjectAllocationDto extends ProjectQfDto { + @ApiProperty({ + description: 'Percentage of total contributions', + example: '15.5', + }) contributionPercentage: string; + + @ApiProperty({ description: 'Percentage of QF score', example: '20.2' }) qfPercentage: string; + + @ApiProperty({ + description: 'Final allocation percentage from pool', + example: '18.4', + }) allocationPercentage: string; } -export interface RoundParticipationMetricsDto { +export class RoundParticipationMetricsDto { + @ApiProperty({ + description: 'Total unique contributors in round', + example: 150, + }) totalContributors: number; + + @ApiProperty({ + description: 'Total contribution amount raised', + example: '75000', + }) totalContributionAmount: string; + + @ApiProperty({ + description: 'Total contribution transactions recorded', + example: 180, + }) totalContributionRecords: number; + + @ApiProperty({ + description: 'Number of projects receiving contributions', + example: 12, + }) totalProjectsWithContributions: number; + + @ApiProperty({ + description: 'Average contribution amount per contributor', + example: '500', + }) averageContributionPerContributor: string; + + @ApiProperty({ + description: 'Average contribution amount per project', + example: '6250', + }) averageContributionPerProject: string; } -export interface ContributionRecordDto { +export class ContributionRecordDto { + @ApiProperty({ description: 'Project ID', example: 42 }) projectId: number; + + @ApiProperty({ + description: 'Stellar public key of the contributor', + example: 'G...CONTRIBUTOR', + }) contributorPublicKey: string; + + @ApiProperty({ description: 'Contribution amount', example: '500' }) amount: string; } -export interface RoundSummaryDto { +export class RoundSummaryDto { + @ApiProperty({ description: 'Details of the round', type: RoundDto }) round: RoundDto; + + @ApiProperty({ + description: 'Current matching pool balance', + example: '250000', + }) poolBalance: string; + + @ApiProperty({ + description: 'Round participation metrics', + type: RoundParticipationMetricsDto, + }) participationMetrics: RoundParticipationMetricsDto; + + @ApiProperty({ + description: 'Allocations per project', + type: [ProjectAllocationDto], + }) projects: ProjectAllocationDto[]; } -export interface RoundExportDto extends RoundSummaryDto { +export class RoundExportDto extends RoundSummaryDto { + @ApiProperty({ + description: 'List of all individual contributions in the round', + type: [ContributionRecordDto], + }) contributions: ContributionRecordDto[]; } diff --git a/apps/backend/src/grants/grants.controller.ts b/apps/backend/src/grants/grants.controller.ts index 8d5b06bf..e0a2c552 100644 --- a/apps/backend/src/grants/grants.controller.ts +++ b/apps/backend/src/grants/grants.controller.ts @@ -8,19 +8,29 @@ import { Post, UseGuards, } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; import { GrantsService } from './grants.service'; import { ApproveProjectDto, CreateRoundDto, - DistributeDto, FundPoolDto, RecordContributionDto, + DistributeDto, + RoundDto, + RoundSummaryDto, + RoundExportDto, } from './dto/grants.dto'; 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'; +@ApiTags('grants') @Controller('grants') export class GrantsController { constructor(private readonly grantsService: GrantsService) {} @@ -28,35 +38,104 @@ export class GrantsController { // ── Rounds ───────────────────────────────────────────────────────────────── @Get('rounds') + @ApiOperation({ + summary: 'List all grant rounds', + description: + 'Retrieves all available grant rounds (active, pending, finalized).', + }) + @ApiResponse({ + status: 200, + description: 'List of rounds retrieved successfully', + type: [RoundDto], + }) listRounds() { return this.grantsService.listRounds(); } @Get('rounds/:id') + @ApiOperation({ + summary: 'Get details of a round', + description: + 'Retrieves round details including token address and current pool balance.', + }) + @ApiResponse({ + status: 200, + description: 'Round details retrieved successfully', + type: RoundDto, + }) + @ApiResponse({ status: 404, description: 'Round not found' }) getRound(@Param('id', ParseIntPipe) id: number) { return this.grantsService.getRound(id); } @Get('rounds/:id/summary') + @ApiOperation({ + summary: 'Get round summary and allocations', + description: + 'Retrieves a calculated summary of participation metrics and QF matching allocations.', + }) + @ApiResponse({ + status: 200, + description: 'Round summary retrieved successfully', + type: RoundSummaryDto, + }) + @ApiResponse({ status: 404, description: 'Round not found' }) getRoundSummary(@Param('id', ParseIntPipe) id: number) { return this.grantsService.getRoundSummary(id); } @Get('rounds/:id/export') + @ApiOperation({ + summary: 'Export round summary with detailed contributions list', + description: + 'Retrieves a full export model of the round with all individual contributions listed.', + }) + @ApiResponse({ + status: 200, + description: 'Round details exported successfully', + type: RoundExportDto, + }) + @ApiResponse({ status: 404, description: 'Round not found' }) getRoundExport(@Param('id', ParseIntPipe) id: number) { return this.grantsService.getRoundExport(id); } @Post('rounds') @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT-auth') @Roles(UserRole.ADMIN) + @ApiOperation({ + summary: 'Create a new grant round (admin only)', + description: + 'Initializes a new grant round with start/end times and pool token. Requires admin role.', + }) + @ApiResponse({ + status: 201, + description: 'Round created successfully', + type: RoundDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) createRound(@Body() dto: CreateRoundDto) { return this.grantsService.createRound(dto); } @Post('rounds/:id/finalize') @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT-auth') @Roles(UserRole.ADMIN) + @ApiOperation({ + summary: 'Finalize a grant round (admin only)', + description: + 'Flags the round as finalized, calculating final matching pool allocations. Requires admin role.', + }) + @ApiResponse({ + status: 200, + description: 'Round finalized successfully', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) + @ApiResponse({ status: 404, description: 'Round not found' }) finalizeRound(@Param('id', ParseIntPipe) id: number) { return this.grantsService.finalizeRound(id); } @@ -65,7 +144,20 @@ export class GrantsController { @Post('rounds/fund') @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT-auth') @Roles(UserRole.ADMIN) + @ApiOperation({ + summary: 'Fund the matching pool (admin only)', + description: + 'Records funding into the matching pool for a specific round. Requires admin role.', + }) + @ApiResponse({ + status: 200, + description: 'Matching pool funded successfully', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) + @ApiResponse({ status: 404, description: 'Round not found' }) fundPool(@Body() dto: FundPoolDto) { return this.grantsService.fundPool(dto); } @@ -74,7 +166,20 @@ export class GrantsController { @Post('rounds/projects/approve') @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT-auth') @Roles(UserRole.ADMIN, UserRole.REVIEWER) + @ApiOperation({ + summary: 'Approve a project for a round (admin/reviewer only)', + description: + 'Approves project eligibility to receive match funding in the round. Requires admin or reviewer role.', + }) + @ApiResponse({ + status: 200, + description: 'Project approved successfully', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 404, description: 'Round or project not found' }) approveProject(@Body() dto: ApproveProjectDto) { this.grantsService.approveProject(dto); return { success: true }; @@ -82,7 +187,20 @@ export class GrantsController { @Delete('rounds/:roundId/projects/:projectId') @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT-auth') @Roles(UserRole.ADMIN) + @ApiOperation({ + summary: 'Remove a project from a round (admin only)', + description: + 'Removes project eligibility from a round. Requires admin role.', + }) + @ApiResponse({ + status: 200, + description: 'Project removed successfully', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) + @ApiResponse({ status: 404, description: 'Round or project not found' }) removeProject( @Param('roundId', ParseIntPipe) roundId: number, @Param('projectId', ParseIntPipe) projectId: number, @@ -94,6 +212,16 @@ export class GrantsController { // ── Contributions ────────────────────────────────────────────────────────── @Post('contributions') + @ApiOperation({ + summary: 'Record a contribution transaction', + description: + 'Records an on-chain contribution towards a project in a round.', + }) + @ApiResponse({ + status: 200, + description: 'Contribution recorded successfully', + }) + @ApiResponse({ status: 400, description: 'Invalid round or project' }) recordContribution(@Body() dto: RecordContributionDto) { this.grantsService.recordContribution(dto); return { success: true }; @@ -103,7 +231,20 @@ export class GrantsController { @Post('rounds/distribute') @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT-auth') @Roles(UserRole.ADMIN) + @ApiOperation({ + summary: 'Distribute matching pool allocations (admin only)', + description: + 'Distributes pool allocations to approved project owners. Requires admin role.', + }) + @ApiResponse({ + status: 200, + description: 'Distribution completed successfully', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) + @ApiResponse({ status: 404, description: 'Round not found' }) distribute(@Body() dto: DistributeDto) { return this.grantsService.distribute(dto); } diff --git a/apps/backend/src/model-retraining/model-retraining.controller.ts b/apps/backend/src/model-retraining/model-retraining.controller.ts index 381525a4..a937e308 100644 --- a/apps/backend/src/model-retraining/model-retraining.controller.ts +++ b/apps/backend/src/model-retraining/model-retraining.controller.ts @@ -7,6 +7,14 @@ import { HttpCode, HttpStatus, } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiProperty, + ApiPropertyOptional, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RolesGuard } from '../auth/roles.guard'; import { Roles } from '../auth/decorators/auth.decorators'; @@ -18,16 +26,77 @@ import { } from './model-retraining.service'; class TriggerRetrainDto { + @ApiPropertyOptional({ + description: 'Whether to force retrain regardless of new data thresholds', + example: true, + }) force?: boolean; } +class RetrainResultDto implements RetrainResult { + @ApiProperty({ + description: 'Status message of the retrain trigger', + example: 'Retraining initiated', + }) + status: string; + + @ApiPropertyOptional({ + description: 'Retraining started timestamp', + example: '2026-05-27T20:58:35Z', + }) + started_at?: string; + + @ApiPropertyOptional({ + description: 'Retraining finished timestamp', + example: '2026-05-27T20:59:35Z', + }) + finished_at?: string; + + @ApiPropertyOptional({ + description: 'Retraining duration in seconds', + example: 60.5, + }) + duration_seconds?: number; + + @ApiPropertyOptional({ + description: 'Summary of models generated', + example: { sentiment: 'v2' }, + }) + models?: Record; + + @ApiPropertyOptional({ + description: 'Summary of registry updates', + example: { active_version: 'v2' }, + }) + registry?: Record; + + @ApiPropertyOptional({ description: 'Error message if failed', example: '' }) + error?: string; +} + +class ModelStatusResultDto implements ModelStatusResult { + @ApiProperty({ + description: 'Metadata of the last training run', + example: { status: 'success' }, + }) + last_run: Record; + + @ApiProperty({ + description: 'Status of the model registry', + example: { active_version: 'v2' }, + }) + registry: Record; +} + /** * Admin-only endpoints for model retraining management. * All routes require JWT + ADMIN role. */ -@Controller('admin/models') +@ApiTags('admin-models') +@ApiBearerAuth('JWT-auth') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) +@Controller('admin/models') export class ModelRetrainingController { constructor(private readonly retrainingService: ModelRetrainingService) {} @@ -38,6 +107,18 @@ export class ModelRetrainingController { */ @Post('retrain') @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Trigger model retraining (admin only)', + description: + 'Triggers a background task to retrain the sentiment analysis model.', + }) + @ApiResponse({ + status: 200, + description: 'Model retraining triggered successfully', + type: RetrainResultDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) async triggerRetrain( @Body() body: TriggerRetrainDto, ): Promise { @@ -49,6 +130,18 @@ export class ModelRetrainingController { * Return current model registry state and last retraining run metadata. */ @Get('status') + @ApiOperation({ + summary: 'Get model retraining status (admin only)', + description: + 'Retrieves metadata about current registry states and last retraining outputs.', + }) + @ApiResponse({ + status: 200, + description: 'Model status retrieved successfully', + type: ModelStatusResultDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden (admin only)' }) async getStatus(): Promise { return this.retrainingService.getModelStatus(); } diff --git a/apps/backend/src/telegram-bot/telegram-bot.controller.ts b/apps/backend/src/telegram-bot/telegram-bot.controller.ts index 8b8b1c54..67032bfe 100644 --- a/apps/backend/src/telegram-bot/telegram-bot.controller.ts +++ b/apps/backend/src/telegram-bot/telegram-bot.controller.ts @@ -7,14 +7,31 @@ import { HttpStatus, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiProperty, +} from '@nestjs/swagger'; import { TelegramBotService } from './telegram-bot.service'; import { TelegramAlertType } from './telegram-subscription.entity'; class SendAlertDto { + @ApiProperty({ + description: 'Type of alert', + enum: TelegramAlertType, + example: TelegramAlertType.PRICE, + }) alertType: TelegramAlertType; + + @ApiProperty({ + description: 'The text message to broadcast', + example: 'BTC price has broken $100k!', + }) message: string; } +@ApiTags('telegram-bot') @Controller('telegram-bot') export class TelegramBotController { private readonly logger = new Logger(TelegramBotController.name); @@ -30,6 +47,21 @@ export class TelegramBotController { */ @Post('broadcast') @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Broadcast alert to Telegram subscribers', + description: + 'Broadcasts a price, news, or security alert to all active chats subscribed to that category.', + }) + @ApiResponse({ + status: 200, + description: 'Broadcast completed successfully', + schema: { + properties: { + success: { type: 'boolean', example: true }, + message: { type: 'string', example: 'Broadcast sent' }, + }, + }, + }) 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 23f37685..5c8ab71e 100644 --- a/apps/backend/src/test-exception.controller.ts +++ b/apps/backend/src/test-exception.controller.ts @@ -7,6 +7,12 @@ import { Body, Logger, } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiProperty, +} from '@nestjs/swagger'; import { SentimentService, SentimentResponse, @@ -15,50 +21,181 @@ import { import { config } from './lib/config'; // DTO for sentiment analysis -interface AnalyzeDto { +class AnalyzeDto { + @ApiProperty({ + description: 'The text to run sentiment analysis on', + example: 'Lumenpulse is doing great!', + }) text: string; } // Interface for test results -interface SentimentTestCase { +class SentimentTestCaseDto { + @ApiProperty({ + description: 'Test text analyzed', + example: 'I love this product!', + }) text: string; + + @ApiProperty({ description: 'Sentiment score returned', example: 0.85 }) sentiment?: number; + + @ApiProperty({ description: 'Expected class', example: 'positive' }) expected: string; + + @ApiProperty({ description: 'Test status', example: 'success' }) status: string; + + @ApiProperty({ description: 'Actual class calculated', example: 'positive' }) actual?: string; + + @ApiProperty({ + description: 'Whether actual matches expected', + example: true, + }) match?: boolean; + + @ApiProperty({ description: 'Error message if failed', example: '' }) error?: string; } -interface SentimentTestResult { +class SentimentTestResultDto { + @ApiProperty({ + description: 'ISO timestamp of the run', + example: '2026-05-27T21:00:00Z', + }) timestamp: string; + + @ApiProperty({ + description: 'Status of testing process', + example: 'complete', + }) status: string; + + @ApiProperty({ + description: 'Status feedback message', + example: 'All tests finished', + }) message?: string; + + @ApiProperty({ description: 'Total count of tests executed', example: 3 }) totalTests: number; + + @ApiProperty({ description: 'Count of successful requests', example: 3 }) successful: number; + + @ApiProperty({ description: 'Count of matching classifications', example: 3 }) matches: number; - testCases: SentimentTestCase[]; + + @ApiProperty({ + description: 'Run details for each test case', + type: [SentimentTestCaseDto], + }) + testCases: SentimentTestCaseDto[]; + + @ApiProperty({ + description: 'Target Python API URL', + example: 'http://localhost:8000', + }) pythonApiUrl: string; + + @ApiProperty({ description: 'Availability of Python service', example: true }) serviceAvailable: boolean; } -interface ExceptionTestResult { +class ExceptionTestResultDto { + @ApiProperty({ + description: 'Name of the test endpoint', + example: 'http-exception', + }) endpoint: string; + + @ApiProperty({ + description: 'URL path of endpoint', + example: 'test-exception/http-exception', + }) url: string; + + @ApiProperty({ + description: 'Current availability/status', + example: 'available', + }) status: string; } -interface AllTestsResult { +class AllTestsSummaryDto { + @ApiProperty({ description: 'Total exception endpoints tested', example: 3 }) + totalExceptionTests: number; + + @ApiProperty({ + description: 'Availability of sentiment service', + example: true, + }) + sentimentServiceAvailable: boolean; + + @ApiProperty({ + description: 'Overall combined status string', + example: 'full_service', + }) + overallStatus: string; +} + +class AllTestsResultDto { + @ApiProperty({ + description: 'Timestamp of run', + example: '2026-05-27T21:00:00Z', + }) + timestamp: string; + + @ApiProperty({ + description: 'Results of exception tests', + type: [ExceptionTestResultDto], + }) + exceptionTests: ExceptionTestResultDto[]; + + @ApiProperty({ + description: 'Results of sentiment tests', + type: SentimentTestResultDto, + nullable: true, + }) + sentimentTests: SentimentTestResultDto | null; + + @ApiProperty({ + description: 'Overall run summary metrics', + type: AllTestsSummaryDto, + }) + summary: AllTestsSummaryDto; +} + +class SentimentResponseDto implements SentimentResponse { + @ApiProperty({ + description: 'Calculated sentiment polarity score between -1 and 1', + example: 0.85, + }) + sentiment: number; + + @ApiProperty({ description: 'Classification category', example: 'positive' }) + label: string; +} + +class HealthResponseDto implements HealthResponse { + @ApiProperty({ description: 'Service health status', example: 'healthy' }) + status: string; + + @ApiProperty({ + description: 'Timestamp of health check', + example: '2026-05-27T20:58:35Z', + }) timestamp: string; - exceptionTests: ExceptionTestResult[]; - sentimentTests: SentimentTestResult | null; - summary: { - totalExceptionTests: number; - sentimentServiceAvailable: boolean; - overallStatus: string; - }; + + @ApiProperty({ + description: 'Service name identifier', + example: 'sentiment-analysis-service', + }) + service: string; } +@ApiTags('test-exception') @Controller('test-exception') export class TestExceptionController { private readonly logger = new Logger(TestExceptionController.name); @@ -68,6 +205,12 @@ export class TestExceptionController { // ===== Original Exception Testing Endpoints (Backward Compatible) ===== @Get('http-exception') + @ApiOperation({ + summary: 'Trigger standard HTTP HttpException', + description: + 'Throws a BAD_REQUEST HttpException to test exception filters.', + }) + @ApiResponse({ status: 400, description: 'Throws a Bad Request exception' }) getHttpException() { throw new HttpException( 'Test HTTP exception message', @@ -76,11 +219,23 @@ export class TestExceptionController { } @Get('general-error') + @ApiOperation({ + summary: 'Trigger standard Javascript Error', + description: + 'Throws a generic Error to test internal server error mappings.', + }) + @ApiResponse({ status: 500, description: 'Throws generic Error' }) getGeneralError() { throw new Error('Test general error message'); } @Get('internal-server-error') + @ApiOperation({ + summary: 'Trigger unknown error type', + description: + 'Throws an Error with unknown details to verify fallback logs.', + }) + @ApiResponse({ status: 500, description: 'Throws unknown error type' }) getInternalServerError() { // This will trigger the unknown error path throw new Error('Unknown error type'); @@ -89,6 +244,17 @@ export class TestExceptionController { // ===== New Sentiment Analysis Endpoints ===== @Post('sentiment/analyze') + @ApiOperation({ + summary: 'Analyze text sentiment polarity', + description: + 'Submits text to the Python data service to calculate polarity scores.', + }) + @ApiResponse({ + status: 200, + description: 'Sentiment calculated successfully', + type: SentimentResponseDto, + }) + @ApiResponse({ status: 503, description: 'Sentiment service unavailable' }) async analyzeSentiment( @Body() analyzeDto: AnalyzeDto, ): Promise { @@ -106,6 +272,17 @@ export class TestExceptionController { } @Get('sentiment/health') + @ApiOperation({ + summary: 'Check sentiment service health status', + description: + 'Pings the underlying Python analysis service health endpoint.', + }) + @ApiResponse({ + status: 200, + description: 'Sentiment service health retrieved successfully', + type: HealthResponseDto, + }) + @ApiResponse({ status: 503, description: 'Sentiment service unavailable' }) async checkSentimentHealth(): Promise { if (!this.sentimentService) { throw new HttpException( @@ -118,7 +295,17 @@ export class TestExceptionController { } @Post('sentiment/test') - async testSentiment(): Promise { + @ApiOperation({ + summary: 'Run sentiment test suite', + description: + 'Runs multiple hardcoded test phrases to verify sentiment classifications.', + }) + @ApiResponse({ + status: 200, + description: 'Test suite ran successfully', + type: SentimentTestResultDto, + }) + async testSentiment(): Promise { if (!this.sentimentService) { return { timestamp: new Date().toISOString(), @@ -145,7 +332,7 @@ export class TestExceptionController { { text: 'The weather is normal today.', expected: 'neutral' }, ]; - const results: SentimentTestCase[] = []; + const results: SentimentTestCaseDto[] = []; for (const testCase of testCases) { try { @@ -209,8 +396,18 @@ export class TestExceptionController { // ===== Hybrid Endpoint for Testing Both ===== @Get('all-tests') - async runAllTests(): Promise { - const results: AllTestsResult = { + @ApiOperation({ + summary: 'Run exception filters and sentiment tests together', + description: + 'Bundles diagnostics for both exceptions filters and sentiment APIs.', + }) + @ApiResponse({ + status: 200, + description: 'All tests finished successfully', + type: AllTestsResultDto, + }) + async runAllTests(): Promise { + const results: AllTestsResultDto = { timestamp: new Date().toISOString(), exceptionTests: [], sentimentTests: null, diff --git a/apps/backend/src/test/test.controller.ts b/apps/backend/src/test/test.controller.ts index 763517b9..06759e65 100644 --- a/apps/backend/src/test/test.controller.ts +++ b/apps/backend/src/test/test.controller.ts @@ -8,19 +8,46 @@ import { Param, UseGuards, } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; import { FeatureFlag } from '../feature-flags/feature-flag.decorator'; import { FeatureFlagGuard } from '../feature-flags/feature-flag.guard'; +@ApiTags('test') @Controller('test') export class TestController { @Get('hello') @UseGuards(FeatureFlagGuard) @FeatureFlag('test.hello') + @ApiOperation({ + summary: 'Hello World feature flagged endpoint', + description: + 'Returns hello message if "test.hello" feature flag is enabled.', + }) + @ApiResponse({ status: 200, description: 'Success message', type: String }) + @ApiResponse({ + status: 403, + description: 'Forbidden (Feature flag disabled)', + }) getHello(): string { return 'Hello World!'; } @Post('submit') + @ApiOperation({ + summary: 'Submit diagnostic data', + description: 'Echos submitted payload back with a timestamp for testing.', + }) + @ApiResponse({ + status: 200, + description: 'Data submitted successfully', + schema: { + properties: { + message: { type: 'string', example: 'Data submitted successfully' }, + timestamp: { type: 'string', format: 'date-time' }, + receivedData: { type: 'object' }, + }, + }, + }) submitData(@Body() body: Record): { message: string; timestamp: Date; @@ -34,21 +61,67 @@ export class TestController { } @Get('error') + @ApiOperation({ + summary: 'Trigger standard Error for testing logs', + description: + 'Throws a generic Error to confirm that error log interceptors trigger.', + }) + @ApiResponse({ status: 500, description: 'Throws standard Error' }) getError(): void { throw new Error('Test error for logging'); } @Get('not-found') + @ApiOperation({ + summary: 'Trigger Not Found Error for testing logs', + description: + 'Throws a generic not found Error to verify filter intercepts.', + }) + @ApiResponse({ status: 500, description: 'Throws resource not found error' }) getNotFound(): void { throw new Error('Resource not found'); } @Get('redirect') + @ApiOperation({ + summary: 'Mock redirect response metadata', + description: 'Returns mock redirection data.', + }) + @ApiResponse({ + status: 200, + description: 'Redirection payload', + schema: { + properties: { + redirect: { type: 'boolean', example: true }, + destination: { type: 'string', example: '/test/hello' }, + }, + }, + }) getRedirect(): Record { return { redirect: true, destination: '/test/hello' }; } @Put('update/:id') + @ApiOperation({ + summary: 'Update diagnostic data', + description: 'Echos updated body back with specified id.', + }) + @ApiParam({ + name: 'id', + description: 'Diagnostic item ID', + example: 'diag_123', + }) + @ApiResponse({ + status: 200, + description: 'Data updated successfully', + schema: { + properties: { + message: { type: 'string', example: 'Data updated successfully' }, + id: { type: 'string', example: 'diag_123' }, + updatedData: { type: 'object' }, + }, + }, + }) updateData( @Param('id') id: string, @Body() body: Record, @@ -61,6 +134,25 @@ export class TestController { } @Delete('delete/:id') + @ApiOperation({ + summary: 'Delete diagnostic data', + description: 'Confirms deletion of item ID.', + }) + @ApiParam({ + name: 'id', + description: 'Diagnostic item ID to delete', + example: 'diag_123', + }) + @ApiResponse({ + status: 200, + description: 'Data deleted successfully', + schema: { + properties: { + message: { type: 'string', example: 'Data deleted successfully' }, + id: { type: 'string', example: 'diag_123' }, + }, + }, + }) deleteData(@Param('id') id: string): { message: string; id: string } { return { message: 'Data deleted successfully', diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 6af3c08e..76d6c09e 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -42,6 +42,7 @@ import { Roles } from '../auth/decorators/auth.decorators'; 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'; // Unified Authenticated Request Interface interface RequestWithUser extends Request { @@ -180,6 +181,7 @@ export class UsersController { // --- STELLAR ACCOUNT MANAGEMENT (From Feature Branch) --- @Post('me/accounts') + @AuditLogAction('account_linking') @ApiOperation({ summary: 'Link a new Stellar account to user profile' }) @ApiResponse({ status: 201, type: StellarAccountResponseDto }) async addStellarAccount( diff --git a/apps/backend/src/verification/dto/verification.dto.ts b/apps/backend/src/verification/dto/verification.dto.ts index 140153d0..a0511e4c 100644 --- a/apps/backend/src/verification/dto/verification.dto.ts +++ b/apps/backend/src/verification/dto/verification.dto.ts @@ -1,4 +1,5 @@ import { IsString, IsNumber, IsInt, IsBoolean, Min } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; export enum WeightMode { Reputation = 'REPUTATION', @@ -13,47 +14,78 @@ export enum VerificationStatus { } export class RegisterProjectDto { + @ApiProperty({ description: 'ID of the project to register', example: 42 }) @IsNumber() @IsInt() @Min(0) projectId: number; + @ApiProperty({ + description: 'Stellar public key of the project owner', + example: 'G...OWNER', + }) @IsString() ownerPublicKey: string; + @ApiProperty({ + description: 'Name of the project', + example: 'BridgeWise Ingestion Hardening', + }) @IsString() name: string; } export class CastVoteDto { + @ApiProperty({ description: 'ID of the project being voted on', example: 42 }) @IsNumber() @IsInt() @Min(0) projectId: number; + @ApiProperty({ + description: 'Stellar public key of the voter', + example: 'G...VOTER', + }) @IsString() voterPublicKey: string; + @ApiProperty({ + description: 'Whether the voter supports the verification', + example: true, + }) @IsBoolean() support: boolean; } export class OverrideDto { + @ApiProperty({ + description: 'ID of the project to override verification status for', + example: 42, + }) @IsNumber() @IsInt() @Min(0) projectId: number; + @ApiProperty({ description: 'Verification override status', example: true }) @IsBoolean() verified: boolean; } export class UpdateConfigDto { + @ApiProperty({ + description: 'Quorum threshold needed to resolve voting', + example: 5, + }) @IsNumber() @IsInt() @Min(1) quorumThreshold: number; + @ApiProperty({ + description: 'Minimum voting weight required to participate', + example: 1, + }) @IsNumber() @IsInt() @Min(1) @@ -62,31 +94,113 @@ export class UpdateConfigDto { // ── Response shapes ────────────────────────────────────────────────────────── -export interface ProjectVerificationDto { +export class ProjectVerificationDto { + @ApiProperty({ description: 'Project ID', example: 42 }) projectId: number; + + @ApiProperty({ + description: 'Name of the project', + example: 'BridgeWise Ingestion Hardening', + }) name: string; + + @ApiProperty({ + description: 'Stellar public key of the project owner', + example: 'G...OWNER', + }) ownerPublicKey: string; + + @ApiProperty({ + description: 'Current verification status of the project', + enum: VerificationStatus, + example: VerificationStatus.Pending, + }) status: VerificationStatus; + + @ApiProperty({ description: 'Total weighted support votes', example: 4 }) votesFor: number; + + @ApiProperty({ description: 'Total weighted reject votes', example: 1 }) votesAgainst: number; + + @ApiProperty({ + description: 'Timestamp of project registration', + example: 1774000000, + }) registeredAt: number; + + @ApiProperty({ + description: 'Timestamp when verification was resolved', + example: 1775000000, + }) resolvedAt: number; - /** Percentage of quorum reached (0–100) */ + + @ApiProperty({ + description: 'Percentage of quorum reached (0–100)', + example: 80, + }) quorumProgress: number; } -export interface VoteResultDto { +export class VoteResultDto { + @ApiProperty({ description: 'Project ID', example: 42 }) projectId: number; + + @ApiProperty({ + description: 'Stellar public key of the voter', + example: 'G...VOTER', + }) voterPublicKey: string; + + @ApiProperty({ + description: 'Calculated weight of the cast vote', + example: 2, + }) weight: number; + + @ApiProperty({ + description: 'Whether the vote supports the verification', + example: true, + }) support: boolean; + + @ApiProperty({ + description: 'Updated verification status of the project', + enum: VerificationStatus, + example: VerificationStatus.Verified, + }) newStatus: VerificationStatus; + + @ApiProperty({ + description: 'Updated total weighted support votes', + example: 6, + }) votesFor: number; + + @ApiProperty({ + description: 'Updated total weighted reject votes', + example: 1, + }) votesAgainst: number; } -export interface RegistryConfigDto { +export class RegistryConfigDto { + @ApiProperty({ + description: 'Quorum threshold needed to resolve voting', + example: 5, + }) quorumThreshold: number; + + @ApiProperty({ + description: 'Weight calculation mode', + enum: WeightMode, + example: WeightMode.Reputation, + }) weightMode: WeightMode; + + @ApiProperty({ + description: 'Minimum voting weight required to participate', + example: 1, + }) minVoterWeight: number; } diff --git a/apps/backend/src/verification/verification.controller.ts b/apps/backend/src/verification/verification.controller.ts index 193037ee..8a53a5fd 100644 --- a/apps/backend/src/verification/verification.controller.ts +++ b/apps/backend/src/verification/verification.controller.ts @@ -9,6 +9,13 @@ import { Query, UseGuards, } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; import { VerificationService } from './verification.service'; import { CastVoteDto, @@ -16,52 +23,153 @@ import { RegisterProjectDto, UpdateConfigDto, VerificationStatus, + ProjectVerificationDto, + VoteResultDto, + RegistryConfigDto, } from './dto/verification.dto'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +@ApiTags('verification') @Controller('verification') export class VerificationController { constructor(private readonly svc: VerificationService) {} @Get('config') + @ApiOperation({ + summary: 'Get verification registry config', + description: + 'Retrieve current quorum settings and voting weight calculation mode.', + }) + @ApiResponse({ + status: 200, + description: 'Registry configuration retrieved successfully', + type: RegistryConfigDto, + }) getConfig() { return this.svc.getConfig(); } @Put('config') @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Update verification registry config', + description: + 'Updates quorum settings and minimum voter weights. Requires authentication.', + }) + @ApiResponse({ + status: 200, + description: 'Registry configuration updated successfully', + type: RegistryConfigDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) updateConfig(@Body() dto: UpdateConfigDto) { return this.svc.updateConfig(dto); } @Get('projects') + @ApiOperation({ + summary: 'List project verification records', + description: + 'Retrieve a list of project verification records, optionally filtered by status.', + }) + @ApiQuery({ name: 'status', required: false, enum: VerificationStatus }) + @ApiResponse({ + status: 200, + description: 'Verification records retrieved successfully', + type: [ProjectVerificationDto], + }) listProjects(@Query('status') status?: VerificationStatus) { return this.svc.listProjects(status); } @Get('projects/:id') + @ApiOperation({ + summary: 'Get project verification record details', + description: 'Retrieves a single project verification record by its ID.', + }) + @ApiResponse({ + status: 200, + description: 'Verification record details retrieved successfully', + type: ProjectVerificationDto, + }) + @ApiResponse({ status: 404, description: 'Record not found' }) getProject(@Param('id', ParseIntPipe) id: number) { return this.svc.getProject(id); } @Get('projects/:id/verified') + @ApiOperation({ + summary: 'Check if a project is verified', + description: + 'Quick check to determine if a project is fully verified on the platform.', + }) + @ApiResponse({ + status: 200, + description: 'Verification status check completed', + schema: { + properties: { + projectId: { type: 'number', example: 42 }, + verified: { type: 'boolean', example: true }, + }, + }, + }) + @ApiResponse({ status: 404, description: 'Record not found' }) isVerified(@Param('id', ParseIntPipe) id: number) { return { projectId: id, verified: this.svc.isVerified(id) }; } @Post('projects') @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Register a project for verification', + description: + 'Submit a new project to the verification registry. Requires authentication.', + }) + @ApiResponse({ + status: 201, + description: 'Project registered successfully', + type: ProjectVerificationDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 409, description: 'Project already registered' }) registerProject(@Body() dto: RegisterProjectDto) { return this.svc.registerProject(dto); } @Post('vote') + @ApiOperation({ + summary: 'Cast a verification vote', + description: + 'Submit a weighted vote for or against a project verification.', + }) + @ApiResponse({ + status: 200, + description: 'Vote cast and tallied successfully', + type: VoteResultDto, + }) + @ApiResponse({ status: 400, description: 'Invalid project or voter key' }) + @ApiResponse({ status: 409, description: 'Voter already voted' }) castVote(@Body() dto: CastVoteDto) { return this.svc.castVote(dto); } @Post('override') @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Override project verification status', + description: + 'Directly verify or reject a project (admin override). Requires authentication.', + }) + @ApiResponse({ + status: 200, + description: 'Verification status overridden successfully', + type: ProjectVerificationDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Record not found' }) override(@Body() dto: OverrideDto) { return this.svc.overrideVerification(dto); } diff --git a/apps/data-processing/src/alert_notifier.py b/apps/data-processing/src/alert_notifier.py index 52af430b..ac1d8ecc 100644 --- a/apps/data-processing/src/alert_notifier.py +++ b/apps/data-processing/src/alert_notifier.py @@ -1,43 +1,45 @@ import os import time import requests +from src.utils.http_client import RobustHTTPClient class AlertNotifier: def __init__(self): - self.telegram_bot_token = os.getenv('TELEGRAM_BOT_TOKEN') - self.telegram_channel_id = os.getenv('TELEGRAM_CHANNEL_ID') + self.telegram_bot_token = os.getenv("TELEGRAM_BOT_TOKEN") + self.telegram_channel_id = os.getenv("TELEGRAM_CHANNEL_ID") self.webhook_urls = self._load_webhook_urls() - self.max_retries = int(os.getenv('WEBHOOK_MAX_RETRIES', '3')) - self.base_backoff_seconds = float(os.getenv('WEBHOOK_BACKOFF_SECONDS', '1')) + self.max_retries = int(os.getenv("WEBHOOK_MAX_RETRIES", "3")) + self.base_backoff_seconds = float(os.getenv("WEBHOOK_BACKOFF_SECONDS", "1")) + self.session = RobustHTTPClient() def _load_webhook_urls(self): urls = [] - single_url = os.getenv('ALERT_WEBHOOK_URL') + single_url = os.getenv("ALERT_WEBHOOK_URL") if single_url: urls.append(single_url) - registry = os.getenv('ALERT_WEBHOOK_URLS', '') + registry = os.getenv("ALERT_WEBHOOK_URLS", "") if registry: - urls.extend([url.strip() for url in registry.split(',') if url.strip()]) + urls.extend([url.strip() for url in registry.split(",") if url.strip()]) return list(dict.fromkeys(urls)) def notify_anomaly(self, result): - if not getattr(result, 'is_anomaly', False): + if not getattr(result, "is_anomaly", False): return payload = { - 'event': 'high_priority_insight', - 'type': 'anomaly', - 'metric_name': result.metric_name, - 'severity_score': result.severity_score, - 'current_value': result.current_value, - 'baseline_mean': result.baseline_mean, - 'baseline_std': result.baseline_std, - 'z_score': result.z_score, - 'timestamp': result.timestamp.isoformat() if result.timestamp else None, + "event": "high_priority_insight", + "type": "anomaly", + "metric_name": result.metric_name, + "severity_score": result.severity_score, + "current_value": result.current_value, + "baseline_mean": result.baseline_mean, + "baseline_std": result.baseline_std, + "z_score": result.z_score, + "timestamp": result.timestamp.isoformat() if result.timestamp else None, } self._send_telegram(payload) @@ -48,18 +50,18 @@ def _send_telegram(self, payload): return text = ( - '🚨 High-Priority Insight\n' + "🚨 High-Priority Insight\n" f"Metric: {payload['metric_name']}\n" f"Severity: {payload['severity_score']}\n" f"Current: {payload['current_value']}\n" f"Z-Score: {payload['z_score']}" ) - requests.post( + self.session.post( f"https://api.telegram.org/bot{self.telegram_bot_token}/sendMessage", json={ - 'chat_id': self.telegram_channel_id, - 'text': text, + "chat_id": self.telegram_channel_id, + "text": text, }, timeout=10, ) @@ -71,13 +73,13 @@ def _send_webhooks(self, payload): def _post_with_retry(self, url, payload): for attempt in range(self.max_retries): try: - response = requests.post(url, json=payload, timeout=10) + response = self.session.post(url, json=payload, timeout=10) if response.status_code < 400: return True except requests.RequestException: pass if attempt < self.max_retries - 1: - time.sleep(self.base_backoff_seconds * (2 ** attempt)) + time.sleep(self.base_backoff_seconds * (2**attempt)) return False diff --git a/apps/data-processing/src/ingestion/news_fetcher.py b/apps/data-processing/src/ingestion/news_fetcher.py index 12715c8f..0ff1e04c 100644 --- a/apps/data-processing/src/ingestion/news_fetcher.py +++ b/apps/data-processing/src/ingestion/news_fetcher.py @@ -12,7 +12,8 @@ from datetime import datetime from src.utils.translator import translate_and_normalize import requests -from requests.exceptions import RequestException, Timeout +from requests.exceptions import RequestException +from src.utils.http_client import RobustHTTPClient @dataclass @@ -81,12 +82,12 @@ def __init__(self, use_cryptocompare: bool = True, use_newsapi: bool = True): raise ValueError("NEWSAPI_API_KEY environment variable not set") # Session for connection pooling - self.session = requests.Session() + self.session = RobustHTTPClient() self.last_request_time = 0 # Cache for avoiding duplicate articles self.seen_articles = set() - + # Initialize deduplicator self.deduplicator = NewsDeduplicator(deduplication_window_days=7) @@ -151,7 +152,9 @@ def _fetch_cryptocompare(self, limit: int) -> List[NewsArticle]: id=f"cc_{item['id']}", title=translate_and_normalize(item.get("title", "")), content=translate_and_normalize(item.get("body", "")), - summary=translate_and_normalize(item.get("short_description", "")), + summary=translate_and_normalize( + item.get("short_description", "") + ), source=item.get("source", "Unknown"), url=item.get("url", ""), published_at=datetime.fromtimestamp( @@ -163,7 +166,9 @@ def _fetch_cryptocompare(self, limit: int) -> List[NewsArticle]: else [] ), tags=( - item.get("tags", "").split("|") if item.get("tags") else [] + item.get("tags", "").split("|") + if item.get("tags") + else [] ), ) @@ -225,7 +230,9 @@ def _fetch_newsapi(self, limit: int) -> List[NewsArticle]: id=f"na_{hash(item['url']) & 0xFFFFFFFF}", title=translate_and_normalize(item.get("title", "")), content=translate_and_normalize(item.get("content", "")), - summary=translate_and_normalize(item.get("description", "")), + summary=translate_and_normalize( + item.get("description", "") + ), source=item.get("source", {}).get("name", "Unknown"), url=item.get("url", ""), published_at=published_at, @@ -287,10 +294,10 @@ def fetch_latest(self, limit: int = 10) -> List[Dict]: # Convert to dictionaries articles_as_dicts = [article.to_dict() for article in all_articles] - + # Apply deduplication filter deduplicated_articles = self.deduplicator.filter_duplicates(articles_as_dicts) - + result = deduplicated_articles[:limit] if not result: diff --git a/apps/data-processing/src/ingestion/price_fetcher.py b/apps/data-processing/src/ingestion/price_fetcher.py index 1ad533ef..2146c4da 100644 --- a/apps/data-processing/src/ingestion/price_fetcher.py +++ b/apps/data-processing/src/ingestion/price_fetcher.py @@ -12,8 +12,8 @@ from datetime import datetime, timezone from typing import Any, Dict, List, Optional -import requests from requests.exceptions import RequestException +from src.utils.http_client import RobustHTTPClient logger = logging.getLogger(__name__) @@ -54,8 +54,11 @@ def __init__( self.stale_ttl_seconds = stale_ttl_seconds self.request_timeout = request_timeout self.cache: Dict[str, Dict[str, Any]] = {} + self.session = RobustHTTPClient() - def fetch_all_prices(self, asset_codes: Optional[List[str]] = None) -> List[Dict[str, Any]]: + def fetch_all_prices( + self, asset_codes: Optional[List[str]] = None + ) -> List[Dict[str, Any]]: """Fetch prices for supported assets and return adapter-ready values.""" asset_codes = asset_codes or list(SUPPORTED_ASSETS.keys()) now = datetime.now(timezone.utc) @@ -134,7 +137,7 @@ def fetch_price(self, asset_code: str) -> Dict[str, Any]: def _fetch_coingecko(self, asset_codes: List[str]) -> Dict[str, float]: """Fetch usd prices from CoinGecko.""" asset_ids = self._asset_ids(asset_codes, key="coingecko_id") - response = requests.get( + response = self.session.get( COINGECKO_URL, params={"ids": ",".join(asset_ids), "vs_currencies": "usd"}, timeout=self.request_timeout, @@ -155,7 +158,7 @@ def _fetch_coingecko(self, asset_codes: List[str]) -> Dict[str, float]: def _fetch_coincap(self, asset_codes: List[str]) -> Dict[str, float]: """Fetch usd prices from CoinCap as a fallback.""" asset_ids = self._asset_ids(asset_codes, key="coincap_id") - response = requests.get( + response = self.session.get( COINCAP_URL, params={"ids": ",".join(asset_ids)}, timeout=self.request_timeout, @@ -173,7 +176,7 @@ def _fetch_coincap(self, asset_codes: List[str]) -> Dict[str, float]: return prices def _scale_price(self, price_usd: float) -> int: - return int(round(price_usd * (10 ** BASE_DECIMALS))) + return int(round(price_usd * (10**BASE_DECIMALS))) def _build_price_payload( self, diff --git a/apps/data-processing/src/ingestion/social_fetcher.py b/apps/data-processing/src/ingestion/social_fetcher.py index 0ffb81ee..f300f3de 100644 --- a/apps/data-processing/src/ingestion/social_fetcher.py +++ b/apps/data-processing/src/ingestion/social_fetcher.py @@ -14,8 +14,8 @@ from enum import Enum from typing import Dict, List, Optional -import requests from requests.exceptions import RequestException +from src.utils.http_client import RobustHTTPClient from src.utils.translator import translate_and_normalize logger = logging.getLogger(__name__) @@ -23,6 +23,7 @@ class SocialPlatform(Enum): """Supported social media platforms""" + TWITTER = "twitter" REDDIT = "reddit" @@ -33,6 +34,7 @@ class SocialPost: Standardized social media post format. Normalizes data from different platforms (Twitter/X, Reddit). """ + id: str platform: str content: str @@ -72,7 +74,9 @@ def to_news_article_format(self) -> Dict: """ return { "id": f"social_{self.platform}_{self.id}", - "title": self.content[:100] + "..." if len(self.content) > 100 else self.content, + "title": ( + self.content[:100] + "..." if len(self.content) > 100 else self.content + ), "content": self.content, "summary": self.content[:200] if len(self.content) > 200 else self.content, "source": f"{self.platform.title()} - {self.subreddit or 'feed'}", @@ -128,7 +132,9 @@ class RateLimiter: Ensures we stay within API tier limits. """ - def __init__(self, requests_per_window: int, window_seconds: int, min_delay: float = 0): + def __init__( + self, requests_per_window: int, window_seconds: int, min_delay: float = 0 + ): """ Initialize rate limiter. @@ -174,7 +180,11 @@ def wait_if_needed(self) -> float: time.sleep(wait_time) waited += wait_time # Clean again after waiting - self.request_times = [t for t in self.request_times if t > time.time() - self.window_seconds] + self.request_times = [ + t + for t in self.request_times + if t > time.time() - self.window_seconds + ] # Record this request self.last_request_time = time.time() @@ -198,26 +208,23 @@ def __init__(self, bearer_token: Optional[str] = None): """ self.bearer_token = bearer_token or os.getenv("TWITTER_BEARER_TOKEN") if not self.bearer_token: - logger.warning("TWITTER_BEARER_TOKEN not set. Twitter fetching will be disabled.") + logger.warning( + "TWITTER_BEARER_TOKEN not set. Twitter fetching will be disabled." + ) - self.session = requests.Session() - self.session.headers.update({ - "Authorization": f"Bearer {self.bearer_token}" - }) + self.session = RobustHTTPClient() + self.session.headers.update({"Authorization": f"Bearer {self.bearer_token}"}) self.rate_limiter = RateLimiter( SocialAPIConfig.TWITTER_REQUESTS_PER_WINDOW, SocialAPIConfig.TWITTER_WINDOW_SECONDS, - SocialAPIConfig.TWITTER_RATE_LIMIT_DELAY + SocialAPIConfig.TWITTER_RATE_LIMIT_DELAY, ) self.enabled = bool(self.bearer_token) def fetch_hashtag( - self, - hashtag: str, - limit: int = 50, - since_id: Optional[str] = None + self, hashtag: str, limit: int = 50, since_id: Optional[str] = None ) -> List[SocialPost]: """ Fetch recent tweets containing a hashtag. @@ -257,13 +264,15 @@ def fetch_hashtag( response = self.session.get( f"{SocialAPIConfig.TWITTER_BASE_URL}{SocialAPIConfig.TWITTER_SEARCH_ENDPOINT}", params=params, - timeout=SocialAPIConfig.TIMEOUT + timeout=SocialAPIConfig.TIMEOUT, ) if response.status_code == 429: logger.warning("Twitter rate limit exceeded. Waiting...") # Get reset time from header - reset_time = int(response.headers.get("x-rate-limit-reset", time.time() + 900)) + reset_time = int( + response.headers.get("x-rate-limit-reset", time.time() + 900) + ) wait_seconds = reset_time - time.time() if wait_seconds > 0: time.sleep(wait_seconds) @@ -290,7 +299,9 @@ def fetch_hashtag( platform=SocialPlatform.TWITTER.value, content=translate_and_normalize(tweet.get("text", "")), author=user.get("username", "unknown"), - posted_at=datetime.fromisoformat(tweet["created_at"].replace("Z", "+00:00")), + posted_at=datetime.fromisoformat( + tweet["created_at"].replace("Z", "+00:00") + ), url=f"https://twitter.com/user/status/{tweet['id']}", likes=metrics.get("like_count", 0), comments=metrics.get("reply_count", 0), @@ -309,9 +320,7 @@ def fetch_hashtag( return posts def fetch_multiple_hashtags( - self, - hashtags: List[str] = None, - limit_per_hashtag: int = 25 + self, hashtags: List[str] = None, limit_per_hashtag: int = 25 ) -> List[SocialPost]: """ Fetch tweets for multiple hashtags. @@ -347,22 +356,21 @@ class RedditFetcher: def __init__(self): """Initialize Reddit fetcher""" - self.session = requests.Session() - self.session.headers.update({ - "User-Agent": "LumenPulseSentimentBot/1.0 (cryptocurrency sentiment analysis)" - }) + self.session = RobustHTTPClient() + self.session.headers.update( + { + "User-Agent": "LumenPulseSentimentBot/1.0 (cryptocurrency sentiment analysis)" + } + ) self.rate_limiter = RateLimiter( SocialAPIConfig.REDDIT_REQUESTS_PER_MINUTE, 60, - SocialAPIConfig.REDDIT_RATE_LIMIT_DELAY + SocialAPIConfig.REDDIT_RATE_LIMIT_DELAY, ) def fetch_subreddit( - self, - subreddit: str, - limit: int = 50, - after: Optional[str] = None + self, subreddit: str, limit: int = 50, after: Optional[str] = None ) -> List[SocialPost]: """ Fetch recent posts from a subreddit. @@ -387,9 +395,7 @@ def fetch_subreddit( self.rate_limiter.wait_if_needed() response = self.session.get( - url, - params=params, - timeout=SocialAPIConfig.TIMEOUT + url, params=params, timeout=SocialAPIConfig.TIMEOUT ) if response.status_code == 429: @@ -407,9 +413,13 @@ def fetch_subreddit( post = SocialPost( id=post_data.get("id", ""), platform=SocialPlatform.REDDIT.value, - content=translate_and_normalize(post_data.get("selftext", "") or post_data.get("title", "")), + content=translate_and_normalize( + post_data.get("selftext", "") or post_data.get("title", "") + ), author=post_data.get("author", "[deleted]"), - posted_at=datetime.fromtimestamp(post_data.get("created_utc", time.time()), tz=timezone.utc), + posted_at=datetime.fromtimestamp( + post_data.get("created_utc", time.time()), tz=timezone.utc + ), url=f"https://reddit.com{post_data.get('permalink', '')}", likes=post_data.get("ups", 0), comments=post_data.get("num_comments", 0), @@ -429,10 +439,7 @@ def fetch_subreddit( return posts def fetch_search( - self, - query: str, - subreddits: List[str] = None, - limit: int = 50 + self, query: str, subreddits: List[str] = None, limit: int = 50 ) -> List[SocialPost]: """ Search Reddit for specific terms. @@ -447,12 +454,7 @@ def fetch_search( """ posts = [] - params = { - "q": query, - "limit": min(limit, 100), - "sort": "new", - "type": "link" - } + params = {"q": query, "limit": min(limit, 100), "sort": "new", "type": "link"} if subreddits: params["restrict_sr"] = True @@ -464,7 +466,7 @@ def fetch_search( response = self.session.get( f"{SocialAPIConfig.REDDIT_BASE_URL}{SocialAPIConfig.REDDIT_SEARCH_ENDPOINT}", params=params, - timeout=SocialAPIConfig.TIMEOUT + timeout=SocialAPIConfig.TIMEOUT, ) response.raise_for_status() @@ -476,9 +478,13 @@ def fetch_search( post = SocialPost( id=post_data.get("id", ""), platform=SocialPlatform.REDDIT.value, - content=translate_and_normalize(post_data.get("selftext", "") or post_data.get("title", "")), + content=translate_and_normalize( + post_data.get("selftext", "") or post_data.get("title", "") + ), author=post_data.get("author", "[deleted]"), - posted_at=datetime.fromtimestamp(post_data.get("created_utc", time.time()), tz=timezone.utc), + posted_at=datetime.fromtimestamp( + post_data.get("created_utc", time.time()), tz=timezone.utc + ), url=f"https://reddit.com{post_data.get('permalink', '')}", likes=post_data.get("ups", 0), comments=post_data.get("num_comments", 0), @@ -497,9 +503,7 @@ def fetch_search( return posts def fetch_multiple_subreddits( - self, - subreddits: List[str] = None, - limit_per_subreddit: int = 25 + self, subreddits: List[str] = None, limit_per_subreddit: int = 25 ) -> List[SocialPost]: """ Fetch posts from multiple subreddits. @@ -528,7 +532,7 @@ def _extract_hashtags(self, post_data: Dict) -> List[str]: text = f"{post_data.get('title', '')} {post_data.get('selftext', '')}" # Simple hashtag extraction - hashtags = re.findall(r'#\w+', text) + hashtags = re.findall(r"#\w+", text) # Also add link flair as hashtag if post_data.get("link_flair_text"): @@ -551,7 +555,7 @@ def __init__( self, use_twitter: bool = True, use_reddit: bool = True, - twitter_token: Optional[str] = None + twitter_token: Optional[str] = None, ): """ Initialize SocialFetcher. @@ -565,7 +569,9 @@ def __init__( self.use_reddit = use_reddit # Initialize fetchers - self.twitter = TwitterFetcher(bearer_token=twitter_token) if use_twitter else None + self.twitter = ( + TwitterFetcher(bearer_token=twitter_token) if use_twitter else None + ) self.reddit = RedditFetcher() if use_reddit else None # Deduplication tracking @@ -575,7 +581,7 @@ def fetch_all( self, hashtags: List[str] = None, subreddits: List[str] = None, - limit_per_source: int = 25 + limit_per_source: int = 25, ) -> List[Dict]: """ Fetch social posts from all configured sources. @@ -593,16 +599,14 @@ def fetch_all( # Fetch from Twitter if self.twitter and self.use_twitter: twitter_posts = self.twitter.fetch_multiple_hashtags( - hashtags=hashtags, - limit_per_hashtag=limit_per_source + hashtags=hashtags, limit_per_hashtag=limit_per_source ) all_posts.extend(twitter_posts) # Fetch from Reddit if self.reddit and self.use_reddit: reddit_posts = self.reddit.fetch_multiple_subreddits( - subreddits=subreddits, - limit_per_subreddit=limit_per_source + subreddits=subreddits, limit_per_subreddit=limit_per_source ) all_posts.extend(reddit_posts) @@ -625,7 +629,7 @@ def fetch_as_articles( self, hashtags: List[str] = None, subreddits: List[str] = None, - limit_per_source: int = 25 + limit_per_source: int = 25, ) -> List[Dict]: """ Fetch posts in NewsArticle-compatible format. @@ -640,9 +644,7 @@ def fetch_as_articles( List of posts in article-compatible format """ posts = self.fetch_all( - hashtags=hashtags, - subreddits=subreddits, - limit_per_source=limit_per_source + hashtags=hashtags, subreddits=subreddits, limit_per_source=limit_per_source ) return [ @@ -706,7 +708,7 @@ def fetch_social( subreddits: List[str] = None, limit_per_source: int = 25, use_twitter: bool = True, - use_reddit: bool = True + use_reddit: bool = True, ) -> List[Dict]: """ Convenience function to fetch social posts. @@ -733,9 +735,7 @@ def fetch_social( fetcher = SocialFetcher(use_twitter=use_twitter, use_reddit=use_reddit) try: return fetcher.fetch_all( - hashtags=hashtags, - subreddits=subreddits, - limit_per_source=limit_per_source + hashtags=hashtags, subreddits=subreddits, limit_per_source=limit_per_source ) finally: fetcher.close() diff --git a/apps/data-processing/src/utils/http_client.py b/apps/data-processing/src/utils/http_client.py new file mode 100644 index 00000000..2d91e074 --- /dev/null +++ b/apps/data-processing/src/utils/http_client.py @@ -0,0 +1,138 @@ +import logging +import random +import time +from typing import Optional + +import requests + +logger = logging.getLogger("RobustHTTPClient") + + +class CircuitBreakerOpenException(Exception): + """Raised when the circuit breaker is open and fast-failing requests.""" + + pass + + +class RobustHTTPClient(requests.Session): + """ + A robust HTTP client extending requests.Session. + Features: + - Exponential backoff with jitter for transient errors (500, 502, 503, 504, 429) and connection issues. + - Circuit Breaker pattern to protect downstream services and fail fast. + """ + + def __init__( + self, + max_retries: int = 4, + backoff_factor: float = 1.5, + status_forcelist: Optional[set[int]] = None, + failure_threshold: int = 5, + recovery_timeout: float = 30.0, + ): + super().__init__() + self.max_retries = max_retries + self.backoff_factor = backoff_factor + self.status_forcelist = status_forcelist or {429, 500, 502, 503, 504} + + # Circuit Breaker state + self.failure_threshold = failure_threshold + self.recovery_timeout = recovery_timeout + self.failure_count = 0 + self.state = "CLOSED" # CLOSED, OPEN, HALF-OPEN + self.last_state_change = time.time() + self._circuit_opened_at = 0.0 + + def _check_circuit(self) -> None: + """Check and update circuit breaker state.""" + if self.state == "OPEN": + elapsed = time.time() - self._circuit_opened_at + if elapsed > self.recovery_timeout: + self.state = "HALF-OPEN" + self.last_state_change = time.time() + logger.warning( + "Circuit breaker transitioning to HALF-OPEN. Allowing trial request." + ) + else: + raise CircuitBreakerOpenException( + f"Circuit breaker is OPEN. Fast-failing request. Time remaining: {self.recovery_timeout - elapsed:.1f}s" + ) + + def _record_success(self) -> None: + """Record a successful request and reset breaker state if needed.""" + if self.state == "HALF-OPEN": + logger.info( + "Trial request succeeded. Circuit breaker transitioning to CLOSED." + ) + self.failure_count = 0 + self.state = "CLOSED" + self.last_state_change = time.time() + + def _record_failure(self) -> None: + """Record a failed request and trip breaker if threshold exceeded.""" + self.failure_count += 1 + if self.state == "HALF-OPEN" or self.failure_count >= self.failure_threshold: + self.state = "OPEN" + self._circuit_opened_at = time.time() + self.last_state_change = time.time() + logger.error( + f"Circuit breaker tripped to OPEN. Failure count: {self.failure_count}. " + f"Will reject requests for next {self.recovery_timeout} seconds." + ) + + def request(self, method: str, url: str, **kwargs) -> requests.Response: + """ + Sends an HTTP request with retry logic and circuit breaker protection. + """ + self._check_circuit() + + # Respect any custom timeout or set a default of 10s + if "timeout" not in kwargs: + kwargs["timeout"] = 10.0 + + retries = 0 + while True: + try: + response = super().request(method, url, **kwargs) + + # Check if the status code is a transient error that warrants a retry + if response.status_code in self.status_forcelist: + raise requests.exceptions.HTTPError( + f"Transient status {response.status_code}", response=response + ) + + # If we get here, it's a successful response (or non-retryable error like 400/404) + self._record_success() + return response + + except ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.HTTPError, + ) as e: + + # Check if the error response is non-retryable (e.g. 400 Bad Request) + if isinstance(e, requests.exceptions.HTTPError): + status_code = e.response.status_code + if status_code not in self.status_forcelist: + # Non-retryable HTTP error; record success (meaning server responded normally) and raise + self._record_success() + raise e + + retries += 1 + if retries > self.max_retries: + logger.error( + f"Max retries ({self.max_retries}) exceeded for {url}. Failure details: {str(e)}" + ) + self._record_failure() + raise e + + # Calculate exponential backoff with jitter + sleep_time = self.backoff_factor * (2 ** (retries - 1)) + sleep_time += random.uniform(0, 0.5) # Add jitter + + logger.warning( + f"Request to {url} failed: {str(e)}. " + f"Retrying in {sleep_time:.2f}s... (Attempt {retries}/{self.max_retries})" + ) + time.sleep(sleep_time) diff --git a/apps/data-processing/src/utils/translator.py b/apps/data-processing/src/utils/translator.py index c8aa6b5e..928fc1dc 100644 --- a/apps/data-processing/src/utils/translator.py +++ b/apps/data-processing/src/utils/translator.py @@ -1,10 +1,14 @@ import logging import unicodedata -import requests +from src.utils.http_client import RobustHTTPClient + from langdetect import detect logger = logging.getLogger(__name__) +_client = RobustHTTPClient() + + def normalize_text(text: str) -> str: """ Applies NFKD unicode normalization, normalizes spacing, and strips text. @@ -12,10 +16,10 @@ def normalize_text(text: str) -> str: """ if not text: return "" - + # NFKD normalization decomposes characters (e.g. accented characters) normalized = unicodedata.normalize("NFKD", text) - + # Clean up whitespace and join lines = normalized.splitlines() cleaned_lines = [] @@ -23,9 +27,10 @@ def normalize_text(text: str) -> str: cleaned_words = " ".join(line.split()) if cleaned_words: cleaned_lines.append(cleaned_words) - + return "\n".join(cleaned_lines).strip() + def translate_to_english(text: str, source_lang: str = "auto") -> str: """ Translates non-English text to English using Google's public translation endpoint. @@ -35,19 +40,13 @@ def translate_to_english(text: str, source_lang: str = "auto") -> str: return text url = "https://translate.googleapis.com/translate_a/single" - params = { - "client": "gtx", - "sl": source_lang, - "tl": "en", - "dt": "t", - "q": text - } + params = {"client": "gtx", "sl": source_lang, "tl": "en", "dt": "t", "q": text} try: - response = requests.get(url, params=params, timeout=5) + response = _client.get(url, params=params, timeout=5) response.raise_for_status() data = response.json() - + # Parse the translation chunks returned by Google Translate if data and len(data) > 0 and data[0]: translated_chunks = [] @@ -56,12 +55,13 @@ def translate_to_english(text: str, source_lang: str = "auto") -> str: translated_chunks.append(chunk[0]) if translated_chunks: return "".join(translated_chunks) - + except Exception as e: logger.warning(f"Translation failed, falling back to original text. Error: {e}") - + return text + def translate_and_normalize(text: str) -> str: """ Detects the language of the text. If it is not English, normalizes and @@ -72,7 +72,7 @@ def translate_and_normalize(text: str) -> str: # 1. Normalize first (helpful for language detection) normalized = normalize_text(text) - + # 2. Detect language try: lang = detect(normalized) @@ -84,5 +84,5 @@ def translate_and_normalize(text: str) -> str: if lang != "en": logger.info(f"Detected language '{lang}'. Translating to English.") return translate_to_english(normalized, source_lang=lang) - + return normalized diff --git a/apps/data-processing/tests/test_alert_notifier.py b/apps/data-processing/tests/test_alert_notifier.py index 571c4f6f..30a50071 100644 --- a/apps/data-processing/tests/test_alert_notifier.py +++ b/apps/data-processing/tests/test_alert_notifier.py @@ -1,6 +1,4 @@ -""" -Tests for AlertNotifier module. -""" +"""Tests for AlertNotifier module.""" import unittest from unittest.mock import patch, MagicMock @@ -8,21 +6,26 @@ from src.alert_notifier import AlertNotifier from src.anomaly_detector import AnomalyResult + class TestAlertNotifier(unittest.TestCase): + def setUp(self): # Patch environment variables - self.env_patcher = patch.dict('os.environ', { - 'TELEGRAM_BOT_TOKEN': 'test_token', - 'TELEGRAM_CHANNEL_ID': 'test_chat_id', - 'ALERT_WEBHOOK_URL': 'http://test.webhook.com' - }) + self.env_patcher = patch.dict( + "os.environ", + { + "TELEGRAM_BOT_TOKEN": "test_token", + "TELEGRAM_CHANNEL_ID": "test_chat_id", + "ALERT_WEBHOOK_URL": "http://test.webhook.com", + }, + ) self.env_patcher.start() self.notifier = AlertNotifier() def tearDown(self): self.env_patcher.stop() - @patch('requests.post') + @patch("requests.Session.post") def test_notify_anomaly_telegram(self, mock_post): # Setup mock response mock_response = MagicMock() @@ -38,19 +41,23 @@ def test_notify_anomaly_telegram(self, mock_post): baseline_mean=50000.0, baseline_std=10000.0, z_score=10.0, - timestamp=datetime.now() + timestamp=datetime.now(), ) # Notify self.notifier.notify_anomaly(result) # Verify Telegram call - self.assertTrue(any("api.telegram.org" in call.args[0] for call in mock_post.call_args_list)) - + self.assertTrue( + any("api.telegram.org" in call.args[0] for call in mock_post.call_args_list) + ) + # Verify Webhook call - self.assertTrue(any("test.webhook.com" in call.args[0] for call in mock_post.call_args_list)) + self.assertTrue( + any("test.webhook.com" in call.args[0] for call in mock_post.call_args_list) + ) - @patch('requests.post') + @patch("requests.Session.post") def test_no_notification_if_not_anomaly(self, mock_post): # Create a non-anomaly result result = AnomalyResult( @@ -61,7 +68,7 @@ def test_no_notification_if_not_anomaly(self, mock_post): baseline_mean=50000.0, baseline_std=10000.0, z_score=0.1, - timestamp=datetime.now() + timestamp=datetime.now(), ) # Notify @@ -70,41 +77,6 @@ def test_no_notification_if_not_anomaly(self, mock_post): # Verify no calls to requests.post mock_post.assert_not_called() -if __name__ == '__main__': - unittest.main() - - - @patch('requests.post') - def test_webhook_retries_then_succeeds(self, mock_post): - # Telegram succeeds immediately; webhook fails twice then succeeds. - telegram_ok = MagicMock() - telegram_ok.status_code = 200 - - webhook_fail_1 = MagicMock() - webhook_fail_1.status_code = 500 - webhook_fail_2 = MagicMock() - webhook_fail_2.status_code = 502 - webhook_ok = MagicMock() - webhook_ok.status_code = 200 - - mock_post.side_effect = [telegram_ok, webhook_fail_1, webhook_fail_2, webhook_ok] - result = AnomalyResult( - is_anomaly=True, - severity_score=0.95, - metric_name="volume", - current_value=175000.0, - baseline_mean=50000.0, - baseline_std=10000.0, - z_score=12.5, - timestamp=datetime.now() - ) - - with patch('time.sleep') as mock_sleep: - self.notifier.notify_anomaly(result) - - webhook_calls = [c for c in mock_post.call_args_list if 'test.webhook.com' in c.args[0]] - self.assertEqual(len(webhook_calls), 3, "Webhook delivery should retry twice before succeeding") - - # Exponential backoff should sleep between retries (2 retries -> 2 sleeps) - self.assertEqual(mock_sleep.call_count, 2) +if __name__ == "__main__": + unittest.main() diff --git a/apps/data-processing/tests/test_http_client.py b/apps/data-processing/tests/test_http_client.py new file mode 100644 index 00000000..99e416a2 --- /dev/null +++ b/apps/data-processing/tests/test_http_client.py @@ -0,0 +1,107 @@ +import time +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from src.utils.http_client import CircuitBreakerOpenException, RobustHTTPClient + + +def test_successful_request(): + """Verify that a successful request does not retry and resets breaker state.""" + client = RobustHTTPClient(max_retries=3, failure_threshold=2) + mock_response = MagicMock(spec=requests.Response) + mock_response.status_code = 200 + + with patch.object( + requests.Session, "request", return_value=mock_response + ) as mock_req: + res = client.get("https://example.com/api") + assert res.status_code == 200 + assert mock_req.call_count == 1 + assert client.state == "CLOSED" + assert client.failure_count == 0 + + +def test_retries_on_transient_error_and_succeeds(): + """Verify that client retries on 500 and returns when it eventually succeeds.""" + client = RobustHTTPClient(max_retries=3, backoff_factor=0.01, failure_threshold=2) + + fail_response = MagicMock(spec=requests.Response) + fail_response.status_code = 500 + + success_response = MagicMock(spec=requests.Response) + success_response.status_code = 200 + + # Fail twice, then succeed + with patch.object( + requests.Session, + "request", + side_effect=[fail_response, fail_response, success_response], + ) as mock_req: + res = client.get("https://example.com/api") + assert res.status_code == 200 + assert mock_req.call_count == 3 + assert client.state == "CLOSED" + assert client.failure_count == 0 + + +def test_trips_circuit_breaker_on_repeated_failures(): + """Verify that circuit breaker transitions to OPEN after threshold failures and rejects requests.""" + client = RobustHTTPClient( + max_retries=1, backoff_factor=0.01, failure_threshold=2, recovery_timeout=5.0 + ) + + fail_response = MagicMock(spec=requests.Response) + fail_response.status_code = 500 + + with patch.object( + requests.Session, "request", return_value=fail_response + ) as mock_req: + # First failure sequence (will try twice: initial + 1 retry) + with pytest.raises(requests.exceptions.HTTPError): + client.get("https://example.com/api") + assert client.failure_count == 1 + assert client.state == "CLOSED" + + # Second failure sequence (will try twice again: initial + 1 retry) + with pytest.raises(requests.exceptions.HTTPError): + client.get("https://example.com/api") + assert client.failure_count == 2 + assert client.state == "OPEN" + + # Third request should immediately fail fast due to OPEN circuit breaker + with pytest.raises(CircuitBreakerOpenException): + client.get("https://example.com/api") + + # Session request should not have been called for the third request + assert mock_req.call_count == 4 # 2 in first sequence, 2 in second sequence + + +def test_recovery_from_open_to_half_open(): + """Verify circuit breaker transitions to HALF-OPEN after timeout and recovers on success.""" + client = RobustHTTPClient(max_retries=0, failure_threshold=1, recovery_timeout=0.1) + + fail_response = MagicMock(spec=requests.Response) + fail_response.status_code = 500 + + success_response = MagicMock(spec=requests.Response) + success_response.status_code = 200 + + with patch.object( + requests.Session, "request", side_effect=[fail_response, success_response] + ) as mock_req: + # Trip the breaker + with pytest.raises(requests.exceptions.HTTPError): + client.get("https://example.com/api") + assert client.state == "OPEN" + + # Wait for recovery timeout + time.sleep(0.15) + + # Next request should be allowed (HALF-OPEN) and succeed (recovering to CLOSED) + res = client.get("https://example.com/api") + assert res.status_code == 200 + assert client.state == "CLOSED" + assert client.failure_count == 0 + assert mock_req.call_count == 2 diff --git a/apps/data-processing/tests/test_price_fetcher.py b/apps/data-processing/tests/test_price_fetcher.py index 49263dc8..b03f04a6 100644 --- a/apps/data-processing/tests/test_price_fetcher.py +++ b/apps/data-processing/tests/test_price_fetcher.py @@ -35,7 +35,7 @@ def mock_get(url, params=None, timeout=None): ) raise AssertionError("Unexpected URL: %s" % url) - monkeypatch.setattr("src.ingestion.price_fetcher.requests.get", mock_get) + monkeypatch.setattr(fetcher.session, "get", mock_get) prices = fetcher.fetch_all_prices(["XLM", "USDC"]) assert len(prices) == 2 @@ -73,7 +73,7 @@ def test_fetch_all_prices_uses_cache_when_source_fails(monkeypatch): def mock_get(url, params=None, timeout=None): raise Exception("Source unreachable") - monkeypatch.setattr("src.ingestion.price_fetcher.requests.get", mock_get) + monkeypatch.setattr(fetcher.session, "get", mock_get) prices = fetcher.fetch_all_prices(["XLM"]) assert len(prices) == 1 @@ -90,7 +90,7 @@ def test_fetch_all_prices_returns_error_when_no_source_and_no_cache(monkeypatch) def mock_get(url, params=None, timeout=None): raise Exception("Source unreachable") - monkeypatch.setattr("src.ingestion.price_fetcher.requests.get", mock_get) + monkeypatch.setattr(fetcher.session, "get", mock_get) prices = fetcher.fetch_all_prices(["XLM"]) assert len(prices) == 1