From f3dc345176427293a9923e5c55ad1c660ea4a13e Mon Sep 17 00:00:00 2001 From: ExcelDsigN-tech Date: Mon, 30 Mar 2026 12:28:48 +0100 Subject: [PATCH 1/2] feat(backend): add transaction enrichment structuring with amount formatting and explorer links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implemented transaction response enrichment interceptor that formats raw database amounts into user-friendly displays and adds Stellar Expert exploration links. ## Changes - **New Interceptor**: `TransactionFormattingInterceptor` in `common/interceptors/` - Formats amounts: `"100000000"` → `"$100.00"` with proper USDC precision (7 decimals) - Generates Stellar Expert links from transaction hashes - Configurable asset mapping system - **Module Updates**: - `transactions.module.ts`: Added global interceptor provider - `transaction-response.dto.ts`: Added `amountFormatted` and `explorerLinks` fields - `transactions.service.ts`: Added asset ID extraction logic ## Acceptance Criteria ✅ - ✅ Construct explicit formatter logic attaching asset mapping symbols - ✅ Evaluate Stellar contract precision parameters intelligently - ✅ Render UI outputs exactly (e.g., `amountFormatted.display: "$500.00"`) - ✅ Dynamically attach canonical Stellar Expert exploration links ## Files Changed - `backend/src/common/interceptors/transaction-formatting.interceptor.ts` (new) - `backend/src/modules/transactions/transactions.module.ts` - `backend/src/modules/transactions/dto/transaction-response.dto.ts` - `backend/src/modules/transactions/transactions.service.ts` - `backend/src/common/interceptors/TRANSACTION_FORMATTING_README.md` (new) ## Testing - ✅ `npm run build` passes - ✅ TypeScript compilation successful - ✅ Interceptor integration verified --- ...latesMilestonesComparisonAndAutoDeposit.ts | 252 ++++++++++++++++++ .../1792000000001-AddUserRewardPoints.ts | 15 ++ .../notifications/notifications.service.ts | 57 ++++ .../savings/dto/compare-products.dto.ts | 16 ++ .../savings/dto/create-auto-deposit.dto.ts | 31 +++ .../dto/create-custom-milestone.dto.ts | 26 ++ .../dto/create-goal-from-template.dto.ts | 35 +++ .../entities/auto-deposit-schedule.entity.ts | 79 ++++++ .../entities/savings-goal-milestone.entity.ts | 58 ++++ .../savings-goal-template-usage.entity.ts | 31 +++ .../entities/savings-goal-template.entity.ts | 40 +++ .../savings-product-performance.entity.ts | 32 +++ .../src/modules/savings/savings.controller.ts | 120 +++++++++ backend/src/modules/savings/savings.module.ts | 29 +- .../src/modules/savings/savings.service.ts | 34 +-- .../services/auto-deposit.service.spec.ts | 94 +++++++ .../savings/services/auto-deposit.service.ts | 211 +++++++++++++++ .../services/goal-milestones.service.spec.ts | 74 +++++ .../services/goal-milestones.service.ts | 201 ++++++++++++++ .../services/goal-templates.service.spec.ts | 80 ++++++ .../services/goal-templates.service.ts | 142 ++++++++++ .../services/milestone-rewards.service.ts | 39 +++ .../product-comparison.service.spec.ts | 74 +++++ .../services/product-comparison.service.ts | 160 +++++++++++ .../src/modules/user/entities/user.entity.ts | 3 + 25 files changed, 1902 insertions(+), 31 deletions(-) create mode 100644 backend/src/migrations/1792000000000-CreateSavingsTemplatesMilestonesComparisonAndAutoDeposit.ts create mode 100644 backend/src/migrations/1792000000001-AddUserRewardPoints.ts create mode 100644 backend/src/modules/savings/dto/compare-products.dto.ts create mode 100644 backend/src/modules/savings/dto/create-auto-deposit.dto.ts create mode 100644 backend/src/modules/savings/dto/create-custom-milestone.dto.ts create mode 100644 backend/src/modules/savings/dto/create-goal-from-template.dto.ts create mode 100644 backend/src/modules/savings/entities/auto-deposit-schedule.entity.ts create mode 100644 backend/src/modules/savings/entities/savings-goal-milestone.entity.ts create mode 100644 backend/src/modules/savings/entities/savings-goal-template-usage.entity.ts create mode 100644 backend/src/modules/savings/entities/savings-goal-template.entity.ts create mode 100644 backend/src/modules/savings/entities/savings-product-performance.entity.ts create mode 100644 backend/src/modules/savings/services/auto-deposit.service.spec.ts create mode 100644 backend/src/modules/savings/services/auto-deposit.service.ts create mode 100644 backend/src/modules/savings/services/goal-milestones.service.spec.ts create mode 100644 backend/src/modules/savings/services/goal-milestones.service.ts create mode 100644 backend/src/modules/savings/services/goal-templates.service.spec.ts create mode 100644 backend/src/modules/savings/services/goal-templates.service.ts create mode 100644 backend/src/modules/savings/services/milestone-rewards.service.ts create mode 100644 backend/src/modules/savings/services/product-comparison.service.spec.ts create mode 100644 backend/src/modules/savings/services/product-comparison.service.ts diff --git a/backend/src/migrations/1792000000000-CreateSavingsTemplatesMilestonesComparisonAndAutoDeposit.ts b/backend/src/migrations/1792000000000-CreateSavingsTemplatesMilestonesComparisonAndAutoDeposit.ts new file mode 100644 index 000000000..6921ae5da --- /dev/null +++ b/backend/src/migrations/1792000000000-CreateSavingsTemplatesMilestonesComparisonAndAutoDeposit.ts @@ -0,0 +1,252 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, +} from 'typeorm'; + +export class CreateSavingsTemplatesMilestonesComparisonAndAutoDeposit1792000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'savings_goal_templates', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'name', type: 'varchar', length: '120', isNullable: false }, + { + name: 'suggestedAmount', + type: 'decimal', + precision: 14, + scale: 2, + isNullable: false, + }, + { + name: 'suggestedDurationMonths', + type: 'int', + isNullable: false, + }, + { name: 'icon', type: 'varchar', length: '120', isNullable: false }, + { name: 'metadata', type: 'jsonb', isNullable: true }, + { name: 'isActive', type: 'boolean', default: true, isNullable: false }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'savings_goal_templates', + new TableIndex({ + name: 'IDX_SAVINGS_GOAL_TEMPLATES_NAME_UNIQUE', + columnNames: ['name'], + isUnique: true, + }), + ); + + await queryRunner.createTable( + new Table({ + name: 'savings_goal_template_usage', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'templateId', type: 'uuid', isNullable: false }, + { name: 'userId', type: 'uuid', isNullable: false }, + { name: 'goalId', type: 'uuid', isNullable: false }, + { name: 'customizations', type: 'jsonb', isNullable: true }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'savings_goal_template_usage', + new TableIndex({ name: 'IDX_SAVINGS_TEMPLATE_USAGE_TEMPLATE', columnNames: ['templateId'] }), + ); + + await queryRunner.createTable( + new Table({ + name: 'savings_goal_milestones', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'goalId', type: 'uuid', isNullable: false }, + { name: 'userId', type: 'uuid', isNullable: false }, + { + name: 'type', + type: 'enum', + enum: ['AUTO', 'CUSTOM'], + default: "'AUTO'", + }, + { name: 'percentage', type: 'int', isNullable: true }, + { name: 'title', type: 'varchar', length: '140', isNullable: true }, + { + name: 'targetAmount', + type: 'decimal', + precision: 14, + scale: 2, + isNullable: true, + }, + { + name: 'achievedAmount', + type: 'decimal', + precision: 14, + scale: 2, + isNullable: true, + }, + { name: 'bonusPoints', type: 'int', default: 0, isNullable: false }, + { name: 'shareCode', type: 'varchar', length: '120', isNullable: true }, + { name: 'metadata', type: 'jsonb', isNullable: true }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'savings_goal_milestones', + new TableIndex({ + name: 'IDX_SAVINGS_GOAL_MILESTONES_GOAL_PERCENTAGE', + columnNames: ['goalId', 'percentage'], + }), + ); + + await queryRunner.createTable( + new Table({ + name: 'savings_product_performance', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'productId', type: 'uuid', isNullable: false }, + { + name: 'apy', + type: 'decimal', + precision: 5, + scale: 2, + isNullable: false, + }, + { + name: 'tvl', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + isNullable: false, + }, + { + name: 'fees', + type: 'decimal', + precision: 6, + scale: 2, + default: 0, + isNullable: false, + }, + { name: 'metadata', type: 'jsonb', isNullable: true }, + { name: 'recordedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'savings_product_performance', + new TableIndex({ + name: 'IDX_SAVINGS_PRODUCT_PERFORMANCE_PRODUCT_DATE', + columnNames: ['productId', 'recordedAt'], + }), + ); + + await queryRunner.createTable( + new Table({ + name: 'savings_auto_deposit_schedules', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'userId', type: 'uuid', isNullable: false }, + { name: 'productId', type: 'uuid', isNullable: false }, + { + name: 'amount', + type: 'decimal', + precision: 14, + scale: 2, + isNullable: false, + }, + { + name: 'frequency', + type: 'enum', + enum: ['DAILY', 'WEEKLY', 'BI_WEEKLY', 'MONTHLY'], + isNullable: false, + }, + { + name: 'status', + type: 'enum', + enum: ['ACTIVE', 'PAUSED', 'CANCELLED'], + default: "'ACTIVE'", + }, + { name: 'nextRunAt', type: 'timestamp', isNullable: false }, + { name: 'lastRunAt', type: 'timestamp', isNullable: true }, + { name: 'pausedAt', type: 'timestamp', isNullable: true }, + { name: 'retryCount', type: 'int', default: 0, isNullable: false }, + { name: 'maxRetries', type: 'int', default: 5, isNullable: false }, + { name: 'lastFailureAt', type: 'timestamp', isNullable: true }, + { name: 'lastFailureReason', type: 'text', isNullable: true }, + { name: 'metadata', type: 'jsonb', isNullable: true }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'savings_auto_deposit_schedules', + new TableIndex({ + name: 'IDX_SAVINGS_AUTO_DEPOSIT_STATUS_NEXT_RUN', + columnNames: ['status', 'nextRunAt'], + }), + ); + + await queryRunner.query(` + INSERT INTO savings_goal_templates (name, "suggestedAmount", "suggestedDurationMonths", icon, metadata, "isActive") + VALUES + ('Emergency Fund', 1000, 6, 'shield', '{"category":"safety"}', true), + ('Vacation', 2500, 12, 'plane', '{"category":"lifestyle"}', true), + ('Car', 12000, 24, 'car', '{"category":"transport"}', true), + ('House', 40000, 48, 'home', '{"category":"housing"}', true), + ('Education', 15000, 36, 'book', '{"category":"education"}', true) + ON CONFLICT (name) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('savings_auto_deposit_schedules', true); + await queryRunner.dropTable('savings_product_performance', true); + await queryRunner.dropTable('savings_goal_milestones', true); + await queryRunner.dropTable('savings_goal_template_usage', true); + await queryRunner.dropTable('savings_goal_templates', true); + } +} diff --git a/backend/src/migrations/1792000000001-AddUserRewardPoints.ts b/backend/src/migrations/1792000000001-AddUserRewardPoints.ts new file mode 100644 index 000000000..a0d2db535 --- /dev/null +++ b/backend/src/migrations/1792000000001-AddUserRewardPoints.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserRewardPoints1792000000001 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "rewardPoints" integer NOT NULL DEFAULT 0`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "users" DROP COLUMN IF EXISTS "rewardPoints"`, + ); + } +} diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts index d68b22a5f..04da2e064 100644 --- a/backend/src/modules/notifications/notifications.service.ts +++ b/backend/src/modules/notifications/notifications.service.ts @@ -35,6 +35,20 @@ export interface ClaimUpdatedEvent { timestamp: Date; } +export interface AutoDepositExecutedEvent { + userId: string; + scheduleId: string; + amount: number; + productId: string; +} + +export interface AutoDepositFailedEvent { + userId: string; + scheduleId: string; + retryCount: number; + reason?: string; +} + @Injectable() export class NotificationsService { private readonly logger = new Logger(NotificationsService.name); @@ -254,6 +268,49 @@ export class NotificationsService { } } + @OnEvent('savings.auto_deposit.executed') + async handleAutoDepositExecuted(event: AutoDepositExecutedEvent) { + try { + await this.createNotification({ + userId: event.userId, + type: NotificationType.DEPOSIT_RECEIVED, + title: 'Auto-deposit completed', + message: `Scheduled deposit of ${event.amount} was completed successfully.`, + metadata: { + scheduleId: event.scheduleId, + productId: event.productId, + }, + }); + } catch (error) { + this.logger.error( + `Error creating auto-deposit success notification for ${event.userId}`, + error, + ); + } + } + + @OnEvent('savings.auto_deposit.failed') + async handleAutoDepositFailed(event: AutoDepositFailedEvent) { + try { + await this.createNotification({ + userId: event.userId, + type: NotificationType.PRODUCT_ALERT_TRIGGERED, + title: 'Auto-deposit failed', + message: `Scheduled deposit failed (attempt ${event.retryCount}). ${event.reason ?? 'Please check your schedule settings.'}`, + metadata: { + scheduleId: event.scheduleId, + retryCount: event.retryCount, + reason: event.reason, + }, + }); + } catch (error) { + this.logger.error( + `Error creating auto-deposit failure notification for ${event.userId}`, + error, + ); + } + } + /** * Handle goal milestone events emitted by the scheduler. * Payload: { userId, goalId, percentage, goalName, metadata? } diff --git a/backend/src/modules/savings/dto/compare-products.dto.ts b/backend/src/modules/savings/dto/compare-products.dto.ts new file mode 100644 index 000000000..a78a0d977 --- /dev/null +++ b/backend/src/modules/savings/dto/compare-products.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator'; + +export class CompareProductsDto { + @ApiProperty({ + type: [String], + description: 'Array of savings product IDs (max 5)', + minItems: 2, + maxItems: 5, + }) + @IsArray() + @ArrayMinSize(2) + @ArrayMaxSize(5) + @IsUUID('4', { each: true }) + productIds: string[]; +} diff --git a/backend/src/modules/savings/dto/create-auto-deposit.dto.ts b/backend/src/modules/savings/dto/create-auto-deposit.dto.ts new file mode 100644 index 000000000..a956c4226 --- /dev/null +++ b/backend/src/modules/savings/dto/create-auto-deposit.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { AutoDepositFrequency } from '../entities/auto-deposit-schedule.entity'; + +export class CreateAutoDepositDto { + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) + @IsUUID('4') + productId: string; + + @ApiProperty({ example: 100 }) + @Type(() => Number) + @IsNumber() + @Min(1) + amount: number; + + @ApiProperty({ enum: AutoDepositFrequency, example: AutoDepositFrequency.WEEKLY }) + @IsEnum(AutoDepositFrequency) + frequency: AutoDepositFrequency; + + @ApiPropertyOptional({ example: 5 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + maxRetries?: number; + + @ApiPropertyOptional({ example: { note: 'Payroll synced' } }) + @IsOptional() + metadata?: Record; +} diff --git a/backend/src/modules/savings/dto/create-custom-milestone.dto.ts b/backend/src/modules/savings/dto/create-custom-milestone.dto.ts new file mode 100644 index 000000000..1dbf58d34 --- /dev/null +++ b/backend/src/modules/savings/dto/create-custom-milestone.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class CreateCustomMilestoneDto { + @ApiProperty({ example: 'Halfway celebration' }) + @IsString() + title: string; + + @ApiPropertyOptional({ example: 40 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(100) + percentage?: number; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @IsBoolean() + shareable?: boolean; + + @ApiPropertyOptional({ example: { badge: 'Momentum Builder' } }) + @IsOptional() + metadata?: Record; +} diff --git a/backend/src/modules/savings/dto/create-goal-from-template.dto.ts b/backend/src/modules/savings/dto/create-goal-from-template.dto.ts new file mode 100644 index 000000000..233e9f5af --- /dev/null +++ b/backend/src/modules/savings/dto/create-goal-from-template.dto.ts @@ -0,0 +1,35 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator'; + +export class CreateGoalFromTemplateDto { + @ApiPropertyOptional({ example: 'Emergency Buffer' }) + @IsOptional() + @IsString() + goalName?: string; + + @ApiPropertyOptional({ example: 1200 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + targetAmount?: number; + + @ApiPropertyOptional({ example: 12 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + durationMonths?: number; + + @ApiPropertyOptional({ example: '2027-12-01' }) + @IsOptional() + @IsDateString() + targetDate?: string; + + @ApiPropertyOptional({ + example: { icon: 'shield', color: '#0F766E' }, + }) + @IsOptional() + metadata?: Record; +} diff --git a/backend/src/modules/savings/entities/auto-deposit-schedule.entity.ts b/backend/src/modules/savings/entities/auto-deposit-schedule.entity.ts new file mode 100644 index 000000000..8b5ac091f --- /dev/null +++ b/backend/src/modules/savings/entities/auto-deposit-schedule.entity.ts @@ -0,0 +1,79 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum AutoDepositFrequency { + DAILY = 'DAILY', + WEEKLY = 'WEEKLY', + BI_WEEKLY = 'BI_WEEKLY', + MONTHLY = 'MONTHLY', +} + +export enum AutoDepositStatus { + ACTIVE = 'ACTIVE', + PAUSED = 'PAUSED', + CANCELLED = 'CANCELLED', +} + +@Entity('savings_auto_deposit_schedules') +@Index(['userId']) +@Index(['status']) +@Index(['nextRunAt']) +export class AutoDepositSchedule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + userId: string; + + @Column('uuid') + productId: string; + + @Column({ type: 'decimal', precision: 14, scale: 2 }) + amount: string; + + @Column({ type: 'enum', enum: AutoDepositFrequency }) + frequency: AutoDepositFrequency; + + @Column({ + type: 'enum', + enum: AutoDepositStatus, + default: AutoDepositStatus.ACTIVE, + }) + status: AutoDepositStatus; + + @Column({ type: 'timestamp' }) + nextRunAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + lastRunAt: Date | null; + + @Column({ type: 'timestamp', nullable: true }) + pausedAt: Date | null; + + @Column({ type: 'int', default: 0 }) + retryCount: number; + + @Column({ type: 'int', default: 5 }) + maxRetries: number; + + @Column({ type: 'timestamp', nullable: true }) + lastFailureAt: Date | null; + + @Column({ type: 'text', nullable: true }) + lastFailureReason: string | null; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/modules/savings/entities/savings-goal-milestone.entity.ts b/backend/src/modules/savings/entities/savings-goal-milestone.entity.ts new file mode 100644 index 000000000..67fe82f9f --- /dev/null +++ b/backend/src/modules/savings/entities/savings-goal-milestone.entity.ts @@ -0,0 +1,58 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +export enum GoalMilestoneType { + AUTO = 'AUTO', + CUSTOM = 'CUSTOM', +} + +@Entity('savings_goal_milestones') +@Index(['goalId']) +@Index(['userId']) +@Index(['goalId', 'percentage']) +export class SavingsGoalMilestone { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + goalId: string; + + @Column('uuid') + userId: string; + + @Column({ + type: 'enum', + enum: GoalMilestoneType, + default: GoalMilestoneType.AUTO, + }) + type: GoalMilestoneType; + + @Column({ type: 'int', nullable: true }) + percentage: number | null; + + @Column({ type: 'varchar', length: 140, nullable: true }) + title: string | null; + + @Column({ type: 'decimal', precision: 14, scale: 2, nullable: true }) + targetAmount: string | null; + + @Column({ type: 'decimal', precision: 14, scale: 2, nullable: true }) + achievedAmount: string | null; + + @Column({ type: 'int', default: 0 }) + bonusPoints: number; + + @Column({ type: 'varchar', length: 120, nullable: true }) + shareCode: string | null; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/modules/savings/entities/savings-goal-template-usage.entity.ts b/backend/src/modules/savings/entities/savings-goal-template-usage.entity.ts new file mode 100644 index 000000000..c56379120 --- /dev/null +++ b/backend/src/modules/savings/entities/savings-goal-template-usage.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity('savings_goal_template_usage') +@Index(['templateId']) +@Index(['userId']) +@Index(['createdAt']) +export class SavingsGoalTemplateUsage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + templateId: string; + + @Column('uuid') + userId: string; + + @Column('uuid') + goalId: string; + + @Column({ type: 'jsonb', nullable: true }) + customizations: Record | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/modules/savings/entities/savings-goal-template.entity.ts b/backend/src/modules/savings/entities/savings-goal-template.entity.ts new file mode 100644 index 000000000..e57c748fb --- /dev/null +++ b/backend/src/modules/savings/entities/savings-goal-template.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity('savings_goal_templates') +@Index(['name'], { unique: true }) +@Index(['isActive']) +export class SavingsGoalTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 120 }) + name: string; + + @Column({ type: 'decimal', precision: 14, scale: 2 }) + suggestedAmount: string; + + @Column({ type: 'int' }) + suggestedDurationMonths: number; + + @Column({ type: 'varchar', length: 120 }) + icon: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/modules/savings/entities/savings-product-performance.entity.ts b/backend/src/modules/savings/entities/savings-product-performance.entity.ts new file mode 100644 index 000000000..d1595a8de --- /dev/null +++ b/backend/src/modules/savings/entities/savings-product-performance.entity.ts @@ -0,0 +1,32 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity('savings_product_performance') +@Index(['productId', 'recordedAt']) +export class SavingsProductPerformance { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + productId: string; + + @Column({ type: 'decimal', precision: 5, scale: 2 }) + apy: string; + + @Column({ type: 'decimal', precision: 18, scale: 2, default: 0 }) + tvl: string; + + @Column({ type: 'decimal', precision: 6, scale: 2, default: 0 }) + fees: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + @CreateDateColumn() + recordedAt: Date; +} diff --git a/backend/src/modules/savings/savings.controller.ts b/backend/src/modules/savings/savings.controller.ts index 2ed9cdc9f..c00986c72 100644 --- a/backend/src/modules/savings/savings.controller.ts +++ b/backend/src/modules/savings/savings.controller.ts @@ -39,6 +39,14 @@ import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { RpcThrottleGuard } from '../../common/guards/rpc-throttle.guard'; import { RecommendationService } from './services/recommendation.service'; +import { GoalTemplatesService } from './services/goal-templates.service'; +import { CreateGoalFromTemplateDto } from './dto/create-goal-from-template.dto'; +import { GoalMilestonesService } from './services/goal-milestones.service'; +import { CreateCustomMilestoneDto } from './dto/create-custom-milestone.dto'; +import { ProductComparisonService } from './services/product-comparison.service'; +import { CompareProductsDto } from './dto/compare-products.dto'; +import { AutoDepositService } from './services/auto-deposit.service'; +import { CreateAutoDepositDto } from './dto/create-auto-deposit.dto'; import { SavingsGoalProgress, UserSubscriptionWithLiveBalance, @@ -50,8 +58,38 @@ export class SavingsController { constructor( private readonly savingsService: SavingsService, private readonly recommendationService: RecommendationService, + private readonly goalTemplatesService: GoalTemplatesService, + private readonly goalMilestonesService: GoalMilestonesService, + private readonly productComparisonService: ProductComparisonService, + private readonly autoDepositService: AutoDepositService, ) {} + @Get('goals/templates') + @ApiOperation({ summary: 'List predefined savings goal templates' }) + @ApiResponse({ status: 200, description: 'Savings goal templates list' }) + async getGoalTemplates() { + return this.goalTemplatesService.listTemplates(); + } + + @Post('goals/from-template/:id') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.CREATED) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create savings goal from template with optional custom values', + }) + async createGoalFromTemplate( + @Param('id') templateId: string, + @Body() dto: CreateGoalFromTemplateDto, + @CurrentUser() user: { id: string; email: string }, + ) { + return this.goalTemplatesService.createGoalFromTemplate( + user.id, + templateId, + dto, + ); + } + @Get('products') @UseInterceptors(CacheInterceptor) @CacheKey('pools_all') @@ -311,4 +349,86 @@ export class SavingsController { ): Promise { return await this.savingsService.deleteGoal(id, user.id); } + + @Get('goals/:id/milestones') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get milestone history for a savings goal' }) + async getGoalMilestones( + @Param('id') goalId: string, + @CurrentUser() user: { id: string; email: string }, + ) { + return this.goalMilestonesService.getGoalMilestones(user.id, goalId); + } + + @Post('goals/:id/milestones/custom') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.CREATED) + @ApiBearerAuth() + @ApiOperation({ summary: 'Add a custom milestone for a savings goal' }) + async addCustomMilestone( + @Param('id') goalId: string, + @Body() dto: CreateCustomMilestoneDto, + @CurrentUser() user: { id: string; email: string }, + ) { + return this.goalMilestonesService.addCustomMilestone(user.id, goalId, dto); + } + + @Post('products/compare') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Compare up to five savings products with projections and ranking', + }) + async compareProducts( + @Body() dto: CompareProductsDto, + @CurrentUser() user: { id: string; email: string }, + ) { + return this.productComparisonService.compareProducts(user.id, dto.productIds); + } + + @Post('auto-deposit/create') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.CREATED) + @ApiBearerAuth() + @ApiOperation({ summary: 'Create recurring auto-deposit schedule' }) + async createAutoDepositSchedule( + @Body() dto: CreateAutoDepositDto, + @CurrentUser() user: { id: string; email: string }, + ) { + return this.autoDepositService.createSchedule(user.id, dto); + } + + @Get('auto-deposit') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'List all recurring auto-deposit schedules for user' }) + async listAutoDepositSchedules( + @CurrentUser() user: { id: string; email: string }, + ) { + return this.autoDepositService.listSchedules(user.id); + } + + @Patch('auto-deposit/:id/pause') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Pause an existing auto-deposit schedule' }) + async pauseAutoDepositSchedule( + @Param('id') id: string, + @CurrentUser() user: { id: string; email: string }, + ) { + return this.autoDepositService.pauseSchedule(user.id, id); + } + + @Delete('auto-deposit/:id') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiBearerAuth() + @ApiOperation({ summary: 'Cancel an auto-deposit schedule' }) + async cancelAutoDepositSchedule( + @Param('id') id: string, + @CurrentUser() user: { id: string; email: string }, + ): Promise { + await this.autoDepositService.cancelSchedule(user.id, id); + } } diff --git a/backend/src/modules/savings/savings.module.ts b/backend/src/modules/savings/savings.module.ts index d269da56d..4ccbe5167 100644 --- a/backend/src/modules/savings/savings.module.ts +++ b/backend/src/modules/savings/savings.module.ts @@ -15,10 +15,22 @@ import { WaitlistEntry } from './entities/waitlist-entry.entity'; import { WaitlistEvent } from './entities/waitlist-event.entity'; import { WaitlistService } from './waitlist.service'; import { WaitlistController } from './waitlist.controller'; +import { SavingsGoalTemplate } from './entities/savings-goal-template.entity'; +import { SavingsGoalTemplateUsage } from './entities/savings-goal-template-usage.entity'; +import { SavingsGoalMilestone } from './entities/savings-goal-milestone.entity'; +import { SavingsProductPerformance } from './entities/savings-product-performance.entity'; +import { AutoDepositSchedule } from './entities/auto-deposit-schedule.entity'; +import { GoalTemplatesService } from './services/goal-templates.service'; +import { GoalMilestonesService } from './services/goal-milestones.service'; +import { ProductComparisonService } from './services/product-comparison.service'; +import { AutoDepositService } from './services/auto-deposit.service'; +import { MailModule } from '../mail/mail.module'; +import { MilestoneRewardsService } from './services/milestone-rewards.service'; @Module({ imports: [ ScheduleModule.forRoot(), + MailModule, TypeOrmModule.forFeature([ SavingsProduct, UserSubscription, @@ -28,10 +40,25 @@ import { WaitlistController } from './waitlist.controller'; User, WaitlistEntry, WaitlistEvent, + SavingsGoalTemplate, + SavingsGoalTemplateUsage, + SavingsGoalMilestone, + SavingsProductPerformance, + AutoDepositSchedule, ]), ], controllers: [SavingsController, WaitlistController], - providers: [SavingsService, PredictiveEvaluatorService, WaitlistService], + providers: [ + SavingsService, + PredictiveEvaluatorService, + RecommendationService, + WaitlistService, + GoalTemplatesService, + GoalMilestonesService, + ProductComparisonService, + AutoDepositService, + MilestoneRewardsService, + ], exports: [SavingsService, WaitlistService], }) export class SavingsModule {} diff --git a/backend/src/modules/savings/savings.service.ts b/backend/src/modules/savings/savings.service.ts index f61d754f1..242f0b29a 100644 --- a/backend/src/modules/savings/savings.service.ts +++ b/backend/src/modules/savings/savings.service.ts @@ -40,7 +40,6 @@ import { Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { SavingsProductVersionAudit } from './entities/savings-product-version-audit.entity'; -import { Repository } from 'typeorm'; import { WaitlistService } from './waitlist.service'; export type SavingsGoalProgress = GoalProgressDto; @@ -170,6 +169,7 @@ export class SavingsService { return savedVersion; } + const previousIsActive = product.isActive; Object.assign(product, dto); const updatedProduct = await this.productRepository.save(product); await this.recordVersionAudit(updatedProduct, { @@ -180,9 +180,6 @@ export class SavingsService { changedFields: this.getChangedFields(product, dto), }, }); - const previousIsActive = product.isActive; - Object.assign(product, dto); - const updatedProduct = await this.productRepository.save(product); await this.syncCapacityState(updatedProduct); await this.invalidatePoolsCache(); @@ -220,32 +217,6 @@ export class SavingsService { relations: ['subscriptions'], }); - const dtos: SavingsProductDto[] = products.map((product) => { - // Calculate TVL by summing active subscriptions - const tvlAmount = product.subscriptions - ? product.subscriptions - .filter((s) => s.status === SubscriptionStatus.ACTIVE) - .reduce((sum, s) => sum + Number(s.amount), 0) - : 0; - - return { - id: product.id, - name: product.name, - type: product.type, - description: product.description, - interestRate: Number(product.interestRate), - minAmount: Number(product.minAmount), - maxAmount: Number(product.maxAmount), - tenureMonths: product.tenureMonths, - contractId: product.contractId, - isActive: product.isActive, - version: product.version ?? 1, - riskLevel: product.riskLevel || RiskLevel.LOW, - tvlAmount, - createdAt: product.createdAt, - updatedAt: product.updatedAt, - }; - }); const dtos: SavingsProductDto[] = await Promise.all( products.map(async (product) => { // Calculate TVL by summing active subscriptions @@ -267,6 +238,7 @@ export class SavingsService { tenureMonths: product.tenureMonths, contractId: product.contractId, isActive: product.isActive, + version: product.version ?? 1, riskLevel: product.riskLevel || RiskLevel.LOW, tvlAmount, maxCapacity: capacity.maxCapacity, @@ -1055,6 +1027,8 @@ export class SavingsService { metadata: options.metadata ?? null, }), ); + } + private async syncCapacityState( product: SavingsProduct, ): Promise { diff --git a/backend/src/modules/savings/services/auto-deposit.service.spec.ts b/backend/src/modules/savings/services/auto-deposit.service.spec.ts new file mode 100644 index 000000000..cc224f78f --- /dev/null +++ b/backend/src/modules/savings/services/auto-deposit.service.spec.ts @@ -0,0 +1,94 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { AutoDepositService } from './auto-deposit.service'; +import { + AutoDepositFrequency, + AutoDepositSchedule, + AutoDepositStatus, +} from '../entities/auto-deposit-schedule.entity'; +import { SavingsProduct } from '../entities/savings-product.entity'; +import { User } from '../../user/entities/user.entity'; +import { SavingsService as BlockchainSavingsService } from '../../blockchain/savings.service'; +import { MailService } from '../../mail/mail.service'; + +describe('AutoDepositService', () => { + let service: AutoDepositService; + + const scheduleRepo = { + create: jest.fn((v) => v), + save: jest.fn(async (v) => ({ id: 'sched-1', ...v })), + find: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const productRepo = { + findOne: jest.fn(), + findOneBy: jest.fn(), + }; + + const userRepo = { + findOne: jest.fn(), + }; + + const blockchainSavingsService = { + invokeContractRead: jest.fn(), + }; + + const mailService = { + sendRawMail: jest.fn(), + }; + + const eventEmitter = { + emit: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutoDepositService, + { + provide: getRepositoryToken(AutoDepositSchedule), + useValue: scheduleRepo, + }, + { provide: getRepositoryToken(SavingsProduct), useValue: productRepo }, + { provide: getRepositoryToken(User), useValue: userRepo }, + { provide: BlockchainSavingsService, useValue: blockchainSavingsService }, + { provide: MailService, useValue: mailService }, + { provide: EventEmitter2, useValue: eventEmitter }, + ], + }).compile(); + + service = module.get(AutoDepositService); + jest.clearAllMocks(); + }); + + it('should create auto-deposit schedule', async () => { + productRepo.findOne.mockResolvedValue({ + id: 'prod-1', + minAmount: 10, + isActive: true, + }); + + const created = await service.createSchedule('user-1', { + productId: 'prod-1', + amount: 100, + frequency: AutoDepositFrequency.WEEKLY, + }); + + expect(created.status).toBe(AutoDepositStatus.ACTIVE); + expect(scheduleRepo.save).toHaveBeenCalled(); + }); + + it('should pause owned schedule', async () => { + scheduleRepo.findOne.mockResolvedValue({ + id: 'sched-1', + userId: 'user-1', + status: AutoDepositStatus.ACTIVE, + }); + + const result = await service.pauseSchedule('user-1', 'sched-1'); + expect(result.status).toBe(AutoDepositStatus.PAUSED); + }); +}); diff --git a/backend/src/modules/savings/services/auto-deposit.service.ts b/backend/src/modules/savings/services/auto-deposit.service.ts new file mode 100644 index 000000000..7474412a0 --- /dev/null +++ b/backend/src/modules/savings/services/auto-deposit.service.ts @@ -0,0 +1,211 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + AutoDepositFrequency, + AutoDepositSchedule, + AutoDepositStatus, +} from '../entities/auto-deposit-schedule.entity'; +import { CreateAutoDepositDto } from '../dto/create-auto-deposit.dto'; +import { SavingsProduct } from '../entities/savings-product.entity'; +import { SavingsService as BlockchainSavingsService } from '../../blockchain/savings.service'; +import { User } from '../../user/entities/user.entity'; +import { MailService } from '../../mail/mail.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +@Injectable() +export class AutoDepositService { + private readonly logger = new Logger(AutoDepositService.name); + + constructor( + @InjectRepository(AutoDepositSchedule) + private readonly scheduleRepository: Repository, + @InjectRepository(SavingsProduct) + private readonly productRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly blockchainSavingsService: BlockchainSavingsService, + private readonly mailService: MailService, + private readonly eventEmitter: EventEmitter2, + ) {} + + async createSchedule(userId: string, dto: CreateAutoDepositDto) { + const product = await this.productRepository.findOne({ + where: { id: dto.productId, isActive: true }, + }); + + if (!product) { + throw new NotFoundException('Savings product not found'); + } + + if (dto.amount < Number(product.minAmount)) { + throw new BadRequestException('Amount is below minimum for selected product'); + } + + const now = new Date(); + const schedule = this.scheduleRepository.create({ + userId, + productId: dto.productId, + amount: String(dto.amount), + frequency: dto.frequency, + status: AutoDepositStatus.ACTIVE, + maxRetries: dto.maxRetries ?? 5, + nextRunAt: this.resolveNextRunAt(now, dto.frequency), + metadata: dto.metadata ?? null, + }); + + return this.scheduleRepository.save(schedule); + } + + async listSchedules(userId: string) { + return this.scheduleRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + } + + async pauseSchedule(userId: string, scheduleId: string) { + const schedule = await this.findOwnedSchedule(userId, scheduleId); + schedule.status = AutoDepositStatus.PAUSED; + schedule.pausedAt = new Date(); + return this.scheduleRepository.save(schedule); + } + + async cancelSchedule(userId: string, scheduleId: string) { + const schedule = await this.findOwnedSchedule(userId, scheduleId); + schedule.status = AutoDepositStatus.CANCELLED; + await this.scheduleRepository.save(schedule); + } + + @Cron(CronExpression.EVERY_5_MINUTES) + async processDueSchedules(): Promise { + const now = new Date(); + const dueSchedules = await this.scheduleRepository + .createQueryBuilder('schedule') + .where('schedule.status = :status', { status: AutoDepositStatus.ACTIVE }) + .andWhere('schedule.nextRunAt <= :now', { now: now.toISOString() }) + .getMany(); + + for (const schedule of dueSchedules) { + await this.processSingleSchedule(schedule); + } + } + + private async processSingleSchedule(schedule: AutoDepositSchedule) { + const user = await this.userRepository.findOne({ + where: { id: schedule.userId }, + select: ['id', 'email', 'name', 'publicKey'], + }); + + if (!user) { + this.logger.warn(`User ${schedule.userId} not found for auto-deposit ${schedule.id}`); + return; + } + + await this.mailService.sendRawMail( + user.email, + 'Upcoming auto-deposit reminder', + `Your scheduled savings auto-deposit of ${schedule.amount} will run shortly.`, + ); + + try { + const product = await this.productRepository.findOneBy({ + id: schedule.productId, + }); + + // Smart contract integration hook: call autosave entrypoint when available. + if (user.publicKey && product?.contractId) { + await this.blockchainSavingsService.invokeContractRead( + product.contractId, + 'autosave', + [], + user.publicKey, + ); + } + + this.eventEmitter.emit('savings.auto_deposit.executed', { + userId: schedule.userId, + scheduleId: schedule.id, + amount: Number(schedule.amount), + productId: schedule.productId, + }); + + schedule.retryCount = 0; + schedule.lastFailureAt = null; + schedule.lastFailureReason = null; + schedule.lastRunAt = new Date(); + schedule.nextRunAt = this.resolveNextRunAt( + schedule.lastRunAt, + schedule.frequency, + ); + await this.scheduleRepository.save(schedule); + } catch (error) { + const previousRetry = schedule.retryCount; + schedule.retryCount += 1; + schedule.lastFailureAt = new Date(); + schedule.lastFailureReason = (error as Error).message; + + if (schedule.retryCount > schedule.maxRetries) { + schedule.status = AutoDepositStatus.PAUSED; + } else { + schedule.nextRunAt = this.resolveRetryTime(previousRetry + 1); + } + + await this.scheduleRepository.save(schedule); + + this.eventEmitter.emit('savings.auto_deposit.failed', { + userId: schedule.userId, + scheduleId: schedule.id, + retryCount: schedule.retryCount, + reason: schedule.lastFailureReason, + }); + } + } + + private resolveRetryTime(retryAttempt: number): Date { + const now = Date.now(); + const delayMinutes = Math.pow(2, retryAttempt); + return new Date(now + delayMinutes * 60 * 1000); + } + + private async findOwnedSchedule(userId: string, scheduleId: string) { + const schedule = await this.scheduleRepository.findOne({ + where: { id: scheduleId, userId }, + }); + + if (!schedule) { + throw new NotFoundException('Auto-deposit schedule not found'); + } + + return schedule; + } + + private resolveNextRunAt(from: Date, frequency: AutoDepositFrequency): Date { + const next = new Date(from); + + switch (frequency) { + case AutoDepositFrequency.DAILY: + next.setDate(next.getDate() + 1); + break; + case AutoDepositFrequency.WEEKLY: + next.setDate(next.getDate() + 7); + break; + case AutoDepositFrequency.BI_WEEKLY: + next.setDate(next.getDate() + 14); + break; + case AutoDepositFrequency.MONTHLY: + next.setMonth(next.getMonth() + 1); + break; + default: + next.setDate(next.getDate() + 1); + } + + return next; + } +} diff --git a/backend/src/modules/savings/services/goal-milestones.service.spec.ts b/backend/src/modules/savings/services/goal-milestones.service.spec.ts new file mode 100644 index 000000000..40496eda9 --- /dev/null +++ b/backend/src/modules/savings/services/goal-milestones.service.spec.ts @@ -0,0 +1,74 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { GoalMilestonesService } from './goal-milestones.service'; +import { SavingsGoalMilestone } from '../entities/savings-goal-milestone.entity'; +import { SavingsGoal } from '../entities/savings-goal.entity'; +import { SavingsService } from '../savings.service'; + +describe('GoalMilestonesService', () => { + let service: GoalMilestonesService; + + const milestoneRepo = { + find: jest.fn(), + create: jest.fn((v) => v), + save: jest.fn(async (v) => ({ id: 'm1', ...v })), + }; + + const goalRepo = { + findOne: jest.fn(), + }; + + const savingsService = { + findMyGoals: jest.fn(), + }; + + const eventEmitter = { + emit: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GoalMilestonesService, + { + provide: getRepositoryToken(SavingsGoalMilestone), + useValue: milestoneRepo, + }, + { provide: getRepositoryToken(SavingsGoal), useValue: goalRepo }, + { provide: SavingsService, useValue: savingsService }, + { provide: EventEmitter2, useValue: eventEmitter }, + ], + }).compile(); + + service = module.get(GoalMilestonesService); + jest.clearAllMocks(); + }); + + it('should auto-create 25% and 50% milestones when progress is 52%', async () => { + goalRepo.findOne.mockResolvedValue({ + id: 'goal-1', + userId: 'user-1', + goalName: 'Emergency Fund', + targetAmount: 1000, + }); + savingsService.findMyGoals.mockResolvedValue([ + { + id: 'goal-1', + currentBalance: 520, + percentageComplete: 52, + projectedBalance: 600, + isOffTrack: false, + }, + ]); + milestoneRepo.find.mockResolvedValue([]); + + const created = await service.detectAutomaticMilestones('user-1', 'goal-1'); + + expect(created).toHaveLength(2); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'goal.milestone', + expect.objectContaining({ percentage: 25 }), + ); + }); +}); diff --git a/backend/src/modules/savings/services/goal-milestones.service.ts b/backend/src/modules/savings/services/goal-milestones.service.ts new file mode 100644 index 000000000..401981c06 --- /dev/null +++ b/backend/src/modules/savings/services/goal-milestones.service.ts @@ -0,0 +1,201 @@ +import { + Injectable, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { randomUUID } from 'crypto'; +import { + GoalMilestoneType, + SavingsGoalMilestone, +} from '../entities/savings-goal-milestone.entity'; +import { SavingsGoal } from '../entities/savings-goal.entity'; +import { SavingsService } from '../savings.service'; +import { CreateCustomMilestoneDto } from '../dto/create-custom-milestone.dto'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +@Injectable() +export class GoalMilestonesService { + private readonly milestoneThresholds = [25, 50, 75, 100] as const; + + constructor( + @InjectRepository(SavingsGoalMilestone) + private readonly milestoneRepository: Repository, + @InjectRepository(SavingsGoal) + private readonly goalRepository: Repository, + private readonly savingsService: SavingsService, + private readonly eventEmitter: EventEmitter2, + ) {} + + async getGoalMilestones(userId: string, goalId: string) { + await this.assertGoalOwnership(userId, goalId); + + await this.detectAutomaticMilestones(userId, goalId); + + return this.milestoneRepository.find({ + where: { goalId, userId }, + order: { createdAt: 'ASC' }, + }); + } + + async addCustomMilestone( + userId: string, + goalId: string, + dto: CreateCustomMilestoneDto, + ): Promise { + const goal = await this.assertGoalOwnership(userId, goalId); + + const progress = await this.getGoalProgress(userId, goalId); + + const milestone = this.milestoneRepository.create({ + goalId, + userId, + type: GoalMilestoneType.CUSTOM, + title: dto.title, + percentage: dto.percentage ?? null, + targetAmount: String(Number(goal.targetAmount)), + achievedAmount: String(progress.currentBalance), + bonusPoints: 0, + shareCode: dto.shareable ? this.generateShareCode() : null, + metadata: dto.metadata ?? null, + }); + + return this.milestoneRepository.save(milestone); + } + + async detectAutomaticMilestones( + userId: string, + goalId: string, + ): Promise { + const goal = await this.assertGoalOwnership(userId, goalId); + const progress = await this.getGoalProgress(userId, goalId); + + const achieved = this.milestoneThresholds.filter( + (threshold) => progress.percentageComplete >= threshold, + ); + + if (achieved.length === 0) { + return []; + } + + const existing = await this.milestoneRepository.find({ + where: { + goalId, + userId, + type: GoalMilestoneType.AUTO, + }, + }); + + const existingPercentages = new Set( + existing + .map((milestone) => milestone.percentage) + .filter((percentage): percentage is number => percentage != null), + ); + + const created: SavingsGoalMilestone[] = []; + for (const percentage of achieved) { + if (existingPercentages.has(percentage)) { + continue; + } + + const bonusPoints = this.resolveBonusPoints(percentage); + const milestone = await this.milestoneRepository.save( + this.milestoneRepository.create({ + goalId, + userId, + type: GoalMilestoneType.AUTO, + percentage, + title: `${percentage}% milestone reached`, + targetAmount: String(Number(goal.targetAmount)), + achievedAmount: String(progress.currentBalance), + bonusPoints, + shareCode: null, + metadata: { + goalName: goal.goalName, + projectedBalance: progress.projectedBalance, + isOffTrack: progress.isOffTrack, + visualProgress: { + percentage: progress.percentageComplete, + currentBalance: progress.currentBalance, + targetAmount: Number(goal.targetAmount), + }, + }, + }), + ); + + this.eventEmitter.emit('goal.milestone', { + userId, + goalId, + percentage, + goalName: goal.goalName, + metadata: { + bonusPoints, + source: 'auto-detection', + }, + }); + + // Reward integration via domain event (listener can grant points/tokens) + this.eventEmitter.emit('goal.milestone.reward', { + userId, + goalId, + percentage, + points: bonusPoints, + }); + + created.push(milestone); + } + + return created; + } + + private async getGoalProgress(userId: string, goalId: string) { + const goals = await this.savingsService.findMyGoals(userId); + const progress = goals.find((goal) => goal.id === goalId); + + if (!progress) { + throw new NotFoundException('Savings goal progress not found'); + } + + return progress; + } + + private async assertGoalOwnership( + userId: string, + goalId: string, + ): Promise { + const goal = await this.goalRepository.findOne({ + where: { id: goalId }, + }); + + if (!goal) { + throw new NotFoundException('Savings goal not found'); + } + + if (goal.userId !== userId) { + throw new ForbiddenException('You cannot access this savings goal'); + } + + return goal; + } + + private resolveBonusPoints(percentage: number): number { + if (percentage === 100) { + return 250; + } + + if (percentage === 75) { + return 120; + } + + if (percentage === 50) { + return 70; + } + + return 40; + } + + private generateShareCode(): string { + return randomUUID().replace(/-/g, '').slice(0, 16); + } +} diff --git a/backend/src/modules/savings/services/goal-templates.service.spec.ts b/backend/src/modules/savings/services/goal-templates.service.spec.ts new file mode 100644 index 000000000..584e10a24 --- /dev/null +++ b/backend/src/modules/savings/services/goal-templates.service.spec.ts @@ -0,0 +1,80 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { GoalTemplatesService } from './goal-templates.service'; +import { SavingsGoalTemplate } from '../entities/savings-goal-template.entity'; +import { SavingsGoalTemplateUsage } from '../entities/savings-goal-template-usage.entity'; +import { SavingsService } from '../savings.service'; + +describe('GoalTemplatesService', () => { + let service: GoalTemplatesService; + + const templateRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn((value) => value), + save: jest.fn(), + }; + + const usageRepo = { + create: jest.fn((value) => value), + save: jest.fn(), + }; + + const savingsService = { + createGoal: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GoalTemplatesService, + { provide: getRepositoryToken(SavingsGoalTemplate), useValue: templateRepo }, + { + provide: getRepositoryToken(SavingsGoalTemplateUsage), + useValue: usageRepo, + }, + { provide: SavingsService, useValue: savingsService }, + ], + }).compile(); + + service = module.get(GoalTemplatesService); + jest.clearAllMocks(); + }); + + it('should seed defaults when no templates exist', async () => { + templateRepo.find.mockResolvedValue([]); + templateRepo.save.mockResolvedValue([{ id: 't1', name: 'Emergency Fund' }]); + + const result = await service.listTemplates(); + + expect(result.length).toBeGreaterThan(0); + expect(templateRepo.save).toHaveBeenCalled(); + }); + + it('should create goal from template with custom target amount', async () => { + templateRepo.findOne.mockResolvedValue({ + id: 'template-1', + name: 'Vacation', + suggestedAmount: '2500', + suggestedDurationMonths: 12, + icon: 'plane', + metadata: {}, + isActive: true, + }); + savingsService.createGoal.mockResolvedValue({ id: 'goal-1' }); + + const result = await service.createGoalFromTemplate('user-1', 'template-1', { + targetAmount: 3000, + }); + + expect(result.goal.id).toBe('goal-1'); + expect(savingsService.createGoal).toHaveBeenCalledWith( + 'user-1', + 'Vacation', + 3000, + expect.any(Date), + expect.any(Object), + ); + expect(usageRepo.save).toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/savings/services/goal-templates.service.ts b/backend/src/modules/savings/services/goal-templates.service.ts new file mode 100644 index 000000000..f71f7fabd --- /dev/null +++ b/backend/src/modules/savings/services/goal-templates.service.ts @@ -0,0 +1,142 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SavingsGoalTemplate } from '../entities/savings-goal-template.entity'; +import { SavingsGoalTemplateUsage } from '../entities/savings-goal-template-usage.entity'; +import { SavingsService } from '../savings.service'; +import { CreateGoalFromTemplateDto } from '../dto/create-goal-from-template.dto'; + +@Injectable() +export class GoalTemplatesService { + constructor( + @InjectRepository(SavingsGoalTemplate) + private readonly templateRepository: Repository, + @InjectRepository(SavingsGoalTemplateUsage) + private readonly usageRepository: Repository, + private readonly savingsService: SavingsService, + ) {} + + async listTemplates(): Promise { + const templates = await this.templateRepository.find({ + where: { isActive: true }, + order: { createdAt: 'ASC' }, + }); + + if (templates.length > 0) { + return templates; + } + + return this.seedDefaultTemplates(); + } + + async createGoalFromTemplate( + userId: string, + templateId: string, + dto: CreateGoalFromTemplateDto, + ) { + const template = await this.templateRepository.findOne({ + where: { id: templateId, isActive: true }, + }); + + if (!template) { + throw new NotFoundException('Savings goal template not found'); + } + + const durationMonths = dto.durationMonths ?? template.suggestedDurationMonths; + const targetDate = dto.targetDate + ? new Date(dto.targetDate) + : this.resolveTargetDate(durationMonths); + + const metadata = { + ...(template.metadata || {}), + ...(dto.metadata || {}), + template: { + id: template.id, + name: template.name, + icon: template.icon, + }, + }; + + const goal = await this.savingsService.createGoal( + userId, + dto.goalName ?? template.name, + dto.targetAmount ?? Number(template.suggestedAmount), + targetDate, + metadata, + ); + + await this.usageRepository.save( + this.usageRepository.create({ + userId, + goalId: goal.id, + templateId: template.id, + customizations: dto, + }), + ); + + return { + goal, + templateUsage: { + templateId: template.id, + templateName: template.name, + }, + }; + } + + private resolveTargetDate(durationMonths: number): Date { + const now = new Date(); + now.setDate(1); + now.setMonth(now.getMonth() + durationMonths); + return now; + } + + private async seedDefaultTemplates(): Promise { + const defaults: Array> = [ + { + name: 'Emergency Fund', + suggestedAmount: '1000', + suggestedDurationMonths: 6, + icon: 'shield', + metadata: { category: 'safety' }, + isActive: true, + }, + { + name: 'Vacation', + suggestedAmount: '2500', + suggestedDurationMonths: 12, + icon: 'plane', + metadata: { category: 'lifestyle' }, + isActive: true, + }, + { + name: 'Car', + suggestedAmount: '12000', + suggestedDurationMonths: 24, + icon: 'car', + metadata: { category: 'transport' }, + isActive: true, + }, + { + name: 'House', + suggestedAmount: '40000', + suggestedDurationMonths: 48, + icon: 'home', + metadata: { category: 'housing' }, + isActive: true, + }, + { + name: 'Education', + suggestedAmount: '15000', + suggestedDurationMonths: 36, + icon: 'book', + metadata: { category: 'education' }, + isActive: true, + }, + ]; + + const entities = defaults.map((template) => + this.templateRepository.create(template), + ); + return this.templateRepository.save(entities); + } +} diff --git a/backend/src/modules/savings/services/milestone-rewards.service.ts b/backend/src/modules/savings/services/milestone-rewards.service.ts new file mode 100644 index 000000000..cfe713ce6 --- /dev/null +++ b/backend/src/modules/savings/services/milestone-rewards.service.ts @@ -0,0 +1,39 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../../user/entities/user.entity'; + +@Injectable() +export class MilestoneRewardsService { + private readonly logger = new Logger(MilestoneRewardsService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + @OnEvent('goal.milestone.reward') + async handleMilestoneReward(event: { + userId: string; + goalId: string; + percentage: number; + points: number; + }) { + const user = await this.userRepository.findOne({ + where: { id: event.userId }, + select: ['id', 'rewardPoints'], + }); + + if (!user) { + return; + } + + user.rewardPoints = Number(user.rewardPoints || 0) + Number(event.points || 0); + await this.userRepository.save(user); + + this.logger.log( + `Awarded ${event.points} reward points to user ${event.userId} for ${event.percentage}% goal milestone`, + ); + } +} diff --git a/backend/src/modules/savings/services/product-comparison.service.spec.ts b/backend/src/modules/savings/services/product-comparison.service.spec.ts new file mode 100644 index 000000000..d6e242b36 --- /dev/null +++ b/backend/src/modules/savings/services/product-comparison.service.spec.ts @@ -0,0 +1,74 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ProductComparisonService } from './product-comparison.service'; +import { SavingsProduct } from '../entities/savings-product.entity'; +import { SavingsGoal } from '../entities/savings-goal.entity'; +import { SavingsProductPerformance } from '../entities/savings-product-performance.entity'; + +describe('ProductComparisonService', () => { + let service: ProductComparisonService; + + const productRepo = { + find: jest.fn(), + }; + + const goalRepo = { + find: jest.fn(), + }; + + const performanceRepo = { + find: jest.fn(), + }; + + const cacheManager = { + get: jest.fn(), + set: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProductComparisonService, + { provide: getRepositoryToken(SavingsProduct), useValue: productRepo }, + { provide: getRepositoryToken(SavingsGoal), useValue: goalRepo }, + { + provide: getRepositoryToken(SavingsProductPerformance), + useValue: performanceRepo, + }, + { provide: CACHE_MANAGER, useValue: cacheManager }, + ], + }).compile(); + + service = module.get(ProductComparisonService); + jest.clearAllMocks(); + }); + + it('should compare products and produce a best option', async () => { + cacheManager.get.mockResolvedValue(undefined); + productRepo.find.mockResolvedValue([ + { + id: 'p1', + name: 'Alpha', + interestRate: 12, + tenureMonths: 12, + riskLevel: 'LOW', + }, + { + id: 'p2', + name: 'Beta', + interestRate: 8, + tenureMonths: 6, + riskLevel: 'MEDIUM', + }, + ]); + goalRepo.find.mockResolvedValue([{ targetAmount: 5000 }]); + performanceRepo.find.mockResolvedValue([]); + + const result = await service.compareProducts('user-1', ['p1', 'p2']); + + expect(result.comparedProducts).toHaveLength(2); + expect(result.bestOption).toHaveProperty('productId'); + expect(cacheManager.set).toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/savings/services/product-comparison.service.ts b/backend/src/modules/savings/services/product-comparison.service.ts new file mode 100644 index 000000000..69cca686d --- /dev/null +++ b/backend/src/modules/savings/services/product-comparison.service.ts @@ -0,0 +1,160 @@ +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { SavingsProduct } from '../entities/savings-product.entity'; +import { SavingsGoal } from '../entities/savings-goal.entity'; +import { SavingsProductPerformance } from '../entities/savings-product-performance.entity'; + +interface ComparedProduct { + id: string; + name: string; + apy: number; + tenureMonths: number | null; + risk: string; + fees: number; + projectedEarnings: number; + historicalPerformance: Array<{ + apy: number; + tvl: number; + recordedAt: Date; + }>; +} + +@Injectable() +export class ProductComparisonService { + constructor( + @InjectRepository(SavingsProduct) + private readonly productRepository: Repository, + @InjectRepository(SavingsGoal) + private readonly goalRepository: Repository, + @InjectRepository(SavingsProductPerformance) + private readonly performanceRepository: Repository, + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + ) {} + + async compareProducts(userId: string, productIds: string[]) { + const uniqueProductIds = [...new Set(productIds)]; + + if (uniqueProductIds.length > 5) { + throw new BadRequestException('You can compare up to 5 products at once'); + } + + const cacheKey = this.buildCacheKey(userId, uniqueProductIds); + const cached = await this.cacheManager.get(cacheKey); + if (cached) { + return { ...cached, cache: { hit: true, key: cacheKey } }; + } + + const products = await this.productRepository.find({ + where: { id: In(uniqueProductIds), isActive: true }, + }); + + if (products.length !== uniqueProductIds.length) { + throw new NotFoundException('One or more savings products were not found'); + } + + const goals = await this.goalRepository.find({ where: { userId } }); + const targetAmount = goals.length + ? Math.max(...goals.map((goal) => Number(goal.targetAmount))) + : 1000; + + const compared: ComparedProduct[] = []; + + for (const product of products) { + const historical = await this.performanceRepository.find({ + where: { productId: product.id }, + order: { recordedAt: 'DESC' }, + take: 12, + }); + + const projectionMonths = product.tenureMonths ?? 12; + const projectedEarnings = this.calculateProjectedEarnings( + targetAmount, + Number(product.interestRate), + projectionMonths, + ); + + compared.push({ + id: product.id, + name: product.name, + apy: Number(product.interestRate), + tenureMonths: product.tenureMonths ?? null, + risk: product.riskLevel, + fees: Number((product as any).fees ?? 0), + projectedEarnings, + historicalPerformance: historical.map((entry) => ({ + apy: Number(entry.apy), + tvl: Number(entry.tvl), + recordedAt: entry.recordedAt, + })), + }); + } + + const best = this.pickBestOption(compared, targetAmount); + + const response = { + comparedProducts: compared, + bestOption: best, + criteria: { + targetAmount, + consideredSignals: ['apy', 'risk', 'fees', 'projection'], + }, + cache: { + hit: false, + key: cacheKey, + }, + }; + + await this.cacheManager.set(cacheKey, response, 5 * 60 * 1000); + + return response; + } + + private pickBestOption(compared: ComparedProduct[], targetAmount: number) { + const scored = compared.map((product) => { + const riskPenalty = + product.risk === 'HIGH' ? 15 : product.risk === 'MEDIUM' ? 7 : 0; + const feePenalty = product.fees; + const projectionScore = + product.projectedEarnings <= 0 + ? 0 + : (product.projectedEarnings / targetAmount) * 100; + + const score = product.apy + projectionScore - riskPenalty - feePenalty; + return { product, score: Number(score.toFixed(2)) }; + }); + + scored.sort((a, b) => b.score - a.score); + const winner = scored[0]; + + return { + productId: winner.product.id, + productName: winner.product.name, + score: winner.score, + reason: `Selected by weighted score across APY, risk, fees, and projected earnings.`, + }; + } + + private calculateProjectedEarnings( + principal: number, + apy: number, + months: number, + ): number { + const annualRate = apy / 100; + const years = months / 12; + const total = principal * Math.pow(1 + annualRate / 12, months); + return Number((total - principal).toFixed(2)); + } + + private buildCacheKey(userId: string, productIds: string[]): string { + const sorted = [...productIds].sort(); + return `savings:comparison:${userId}:${sorted.join(':')}`; + } +} diff --git a/backend/src/modules/user/entities/user.entity.ts b/backend/src/modules/user/entities/user.entity.ts index 3977fd534..1db5326ee 100644 --- a/backend/src/modules/user/entities/user.entity.ts +++ b/backend/src/modules/user/entities/user.entity.ts @@ -71,6 +71,9 @@ export class User { @Column({ type: 'boolean', default: true }) isActive: boolean; + @Column({ type: 'int', default: 0 }) + rewardPoints: number; + @Column({ type: 'timestamp', nullable: true }) lastLoginAt: Date | null; From a776e95a51f2ffc56951e89d2f058cd2cd0123f0 Mon Sep 17 00:00:00 2001 From: ExcelDsigN-tech Date: Mon, 30 Mar 2026 14:35:41 +0100 Subject: [PATCH 2/2] chore: stage and sync pending repository updates --- backend/src/app.module.ts | 4 +- backend/src/auth/auth.service.spec.ts | 7 + backend/src/auth/dto/auth.dto.ts | 13 +- .../common/interceptors/cache.interceptor.ts | 26 +- .../graceful-shutdown.interceptor.ts | 4 +- .../services/graceful-shutdown.service.ts | 21 +- backend/src/config/configuration.ts | 25 +- ...775400000000-CreateInterestHistoryTable.ts | 4 +- .../1776000000000-CreateReferralsTable.ts | 8 +- ...latesMilestonesComparisonAndAutoDeposit.ts | 30 ++- .../admin-analytics.controller.ts | 35 ++- .../admin-analytics.service.spec.ts | 56 +++++ .../admin-analytics.service.ts | 144 ++++++++--- .../admin/admin-audit-logs.controller.ts | 61 ++++- .../modules/admin/admin-audit-logs.service.ts | 94 ++++++-- .../admin/admin-disputes.controller.ts | 59 ++++- .../modules/admin/admin-disputes.service.ts | 225 ++++++++++++++---- .../admin/admin-notifications.controller.ts | 14 +- .../admin/admin-notifications.service.ts | 97 ++++++-- .../modules/admin/admin-savings.controller.ts | 42 ---- backend/src/modules/admin/admin.module.ts | 2 - .../modules/admin/dto/admin-analytics.dto.ts | 10 +- .../modules/admin/dto/admin-audit-log.dto.ts | 18 +- .../modules/admin/dto/admin-dispute.dto.ts | 21 +- .../admin/dto/admin-notification.dto.ts | 51 +++- .../blockchain/balance-sync.service.spec.ts | 30 ++- .../blockchain/balance-sync.service.ts | 59 ++++- .../blockchain/blockchain.controller.spec.ts | 10 +- .../blockchain/blockchain.controller.ts | 9 +- .../blockchain/savings.service.spec.ts | 3 +- .../src/modules/blockchain/savings.service.ts | 5 +- .../modules/cache/cache-strategy.service.ts | 24 +- backend/src/modules/cache/cache.module.ts | 13 +- .../src/modules/disputes/disputes.module.ts | 15 +- .../disputes/entities/dispute.entity.ts | 6 +- .../modules/health/health-history.service.ts | 8 +- .../src/modules/health/health.controller.ts | 9 +- .../indicators/external-services.health.ts | 34 +-- .../entities/notification.entity.ts | 3 + .../performance/query-logger.service.ts | 40 +--- .../referrals/admin-referrals.controller.ts | 34 ++- .../modules/referrals/campaigns.service.ts | 17 +- .../src/modules/referrals/dto/campaign.dto.ts | 14 +- .../src/modules/referrals/dto/referral.dto.ts | 15 +- .../referrals/referral-events.listener.ts | 17 +- .../modules/referrals/referrals.controller.ts | 27 ++- .../referrals/referrals.integration.spec.ts | 4 +- .../referrals/referrals.service.spec.ts | 79 ++++-- .../modules/referrals/referrals.service.ts | 65 +++-- .../savings/dto/create-auto-deposit.dto.ts | 5 +- .../dto/create-custom-milestone.dto.ts | 9 +- .../dto/create-goal-from-template.dto.ts | 8 +- .../savings.controller.enhanced.spec.ts | 42 +++- .../src/modules/savings/savings.controller.ts | 9 +- .../modules/savings/savings.service.spec.ts | 10 + .../services/auto-deposit.service.spec.ts | 5 +- .../savings/services/auto-deposit.service.ts | 8 +- .../services/goal-templates.service.spec.ts | 15 +- .../services/goal-templates.service.ts | 3 +- .../services/interest-calculation.service.ts | 37 ++- .../services/milestone-rewards.service.ts | 3 +- .../services/product-comparison.service.ts | 4 +- .../services/recommendation.service.ts | 33 +-- 63 files changed, 1313 insertions(+), 489 deletions(-) diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 07e7da816..45a33451b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -24,7 +24,7 @@ import { ChallengesModule } from './modules/challenges/challenges.module'; import { AlertsModule } from './modules/alerts/alerts.module'; import { AdminModule } from './modules/admin/admin.module'; import { MailModule } from './modules/mail/mail.module'; -import { RedisCacheModule } from './modules/cache/cache.module'; +import { CacheModule } from './modules/cache/cache.module'; import { WebhooksModule } from './modules/webhooks/webhooks.module'; import { ClaimsModule } from './modules/claims/claims.module'; import { DisputesModule } from './modules/disputes/disputes.module'; @@ -172,7 +172,7 @@ const envValidationSchema = Joi.object({ }, }), AuthModule, - RedisCacheModule, + CacheModule, HealthModule, BlockchainModule, UserModule, diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 1abeeba89..fe38bbff0 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthService } from './auth.service'; import { UserService } from '../modules/user/user.service'; import { JwtService } from '@nestjs/jwt'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { ConflictException, UnauthorizedException } from '@nestjs/common'; // import { CACHE_MANAGER } from '@nestjs/cache-manager'; import * as bcrypt from 'bcrypt'; @@ -42,6 +43,12 @@ describe('AuthService', () => { sign: jest.fn().mockReturnValue('mock-token'), }, }, + { + provide: EventEmitter2, + useValue: { + emit: jest.fn(), + }, + }, // { // provide: CACHE_MANAGER, // useValue: mockCacheManager, diff --git a/backend/src/auth/dto/auth.dto.ts b/backend/src/auth/dto/auth.dto.ts index a240fea5b..0064b9896 100644 --- a/backend/src/auth/dto/auth.dto.ts +++ b/backend/src/auth/dto/auth.dto.ts @@ -1,4 +1,10 @@ -import { IsEmail, IsString, MinLength, MaxLength, IsOptional } from 'class-validator'; +import { + IsEmail, + IsString, + MinLength, + MaxLength, + IsOptional, +} from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsStellarPublicKey } from '../../common/validators/is-stellar-key.validator'; @@ -17,7 +23,10 @@ export class RegisterDto { @IsString() name?: string; - @ApiPropertyOptional({ example: 'ABC12345', description: 'Referral code from another user' }) + @ApiPropertyOptional({ + example: 'ABC12345', + description: 'Referral code from another user', + }) @IsOptional() @IsString() referralCode?: string; diff --git a/backend/src/common/interceptors/cache.interceptor.ts b/backend/src/common/interceptors/cache.interceptor.ts index 78e7565f3..c6a382a07 100644 --- a/backend/src/common/interceptors/cache.interceptor.ts +++ b/backend/src/common/interceptors/cache.interceptor.ts @@ -4,8 +4,8 @@ import { ExecutionContext, CallHandler, } from '@nestjs/common'; -import { Observable, of } from 'rxjs'; -import { tap } from 'rxjs/operators'; +import { Observable, from, of } from 'rxjs'; +import { switchMap, tap } from 'rxjs/operators'; import { CacheStrategyService } from '../../modules/cache/cache-strategy.service'; @Injectable() @@ -23,16 +23,18 @@ export class CacheInterceptor implements NestInterceptor { const cacheKey = `${url}:${JSON.stringify(query)}`; - return this.cacheStrategy.get(cacheKey).then((cached) => { - if (cached) { - return of(cached); - } + return from(this.cacheStrategy.get(cacheKey)).pipe( + switchMap((cached) => { + if (cached) { + return of(cached); + } - return next.handle().pipe( - tap((data) => { - this.cacheStrategy.set(cacheKey, data); - }), - ); - }); + return next.handle().pipe( + tap((data) => { + this.cacheStrategy.set(cacheKey, data); + }), + ); + }), + ); } } diff --git a/backend/src/common/interceptors/graceful-shutdown.interceptor.ts b/backend/src/common/interceptors/graceful-shutdown.interceptor.ts index 911b10b71..af315da25 100644 --- a/backend/src/common/interceptors/graceful-shutdown.interceptor.ts +++ b/backend/src/common/interceptors/graceful-shutdown.interceptor.ts @@ -4,7 +4,7 @@ import { ExecutionContext, CallHandler, } from '@nestjs/common'; -import { Observable } from 'rxjs'; +import { EMPTY, Observable } from 'rxjs'; import { finalize } from 'rxjs/operators'; import { GracefulShutdownService } from '../services/graceful-shutdown.service'; @@ -20,7 +20,7 @@ export class GracefulShutdownInterceptor implements NestInterceptor { statusCode: 503, message: 'Service is shutting down', }); - return; + return EMPTY; } this.gracefulShutdown.incrementActiveRequests(); diff --git a/backend/src/common/services/graceful-shutdown.service.ts b/backend/src/common/services/graceful-shutdown.service.ts index 540478b6e..12cbef084 100644 --- a/backend/src/common/services/graceful-shutdown.service.ts +++ b/backend/src/common/services/graceful-shutdown.service.ts @@ -49,9 +49,7 @@ export class GracefulShutdownService implements OnApplicationShutdown { await this.closeRedis(); const shutdownDuration = Date.now() - shutdownStartTime; - this.logger.log( - `Graceful shutdown completed in ${shutdownDuration}ms`, - ); + this.logger.log(`Graceful shutdown completed in ${shutdownDuration}ms`); } private async waitForInFlightRequests(): Promise { @@ -93,7 +91,22 @@ export class GracefulShutdownService implements OnApplicationShutdown { try { if (this.cacheManager) { this.logger.log('Closing Redis connections...'); - await this.cacheManager.reset(); + const cacheAny = this.cacheManager as unknown as { + clear?: () => Promise; + stores?: Array<{ disconnect?: () => Promise }>; + }; + + if (cacheAny.clear) { + await cacheAny.clear(); + } + + if (Array.isArray(cacheAny.stores)) { + for (const store of cacheAny.stores) { + if (store?.disconnect) { + await store.disconnect(); + } + } + } this.logger.log('Redis connections closed'); } } catch (error) { diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index 4790c0398..1f6722c9b 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -93,10 +93,25 @@ export default () => ({ ), }, balanceSync: { - cacheTtlSeconds: parseInt(process.env.BALANCE_CACHE_TTL_SECONDS || '300', 10), - pollIntervalMs: parseInt(process.env.BALANCE_POLL_INTERVAL_MS || '5000', 10), - reconnectInitialDelayMs: parseInt(process.env.BALANCE_RECONNECT_INIT_MS || '1000', 10), - reconnectMaxDelayMs: parseInt(process.env.BALANCE_RECONNECT_MAX_MS || '60000', 10), - metricsPersistIntervalMs: parseInt(process.env.BALANCE_METRICS_PERSIST_MS || '60000', 10), + cacheTtlSeconds: parseInt( + process.env.BALANCE_CACHE_TTL_SECONDS || '300', + 10, + ), + pollIntervalMs: parseInt( + process.env.BALANCE_POLL_INTERVAL_MS || '5000', + 10, + ), + reconnectInitialDelayMs: parseInt( + process.env.BALANCE_RECONNECT_INIT_MS || '1000', + 10, + ), + reconnectMaxDelayMs: parseInt( + process.env.BALANCE_RECONNECT_MAX_MS || '60000', + 10, + ), + metricsPersistIntervalMs: parseInt( + process.env.BALANCE_METRICS_PERSIST_MS || '60000', + 10, + ), }, }); diff --git a/backend/src/migrations/1775400000000-CreateInterestHistoryTable.ts b/backend/src/migrations/1775400000000-CreateInterestHistoryTable.ts index af9b2bb90..93ccf6147 100644 --- a/backend/src/migrations/1775400000000-CreateInterestHistoryTable.ts +++ b/backend/src/migrations/1775400000000-CreateInterestHistoryTable.ts @@ -6,9 +6,7 @@ import { TableIndex, } from 'typeorm'; -export class CreateInterestHistoryTable1775400000000 - implements MigrationInterface -{ +export class CreateInterestHistoryTable1775400000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ diff --git a/backend/src/migrations/1776000000000-CreateReferralsTable.ts b/backend/src/migrations/1776000000000-CreateReferralsTable.ts index 950f6dd32..7fc6badd2 100644 --- a/backend/src/migrations/1776000000000-CreateReferralsTable.ts +++ b/backend/src/migrations/1776000000000-CreateReferralsTable.ts @@ -1,4 +1,10 @@ -import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey } from 'typeorm'; +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, + TableForeignKey, +} from 'typeorm'; export class CreateReferralsTable1776000000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { diff --git a/backend/src/migrations/1792000000000-CreateSavingsTemplatesMilestonesComparisonAndAutoDeposit.ts b/backend/src/migrations/1792000000000-CreateSavingsTemplatesMilestonesComparisonAndAutoDeposit.ts index 6921ae5da..d9b164c5d 100644 --- a/backend/src/migrations/1792000000000-CreateSavingsTemplatesMilestonesComparisonAndAutoDeposit.ts +++ b/backend/src/migrations/1792000000000-CreateSavingsTemplatesMilestonesComparisonAndAutoDeposit.ts @@ -1,13 +1,6 @@ -import { - MigrationInterface, - QueryRunner, - Table, - TableIndex, -} from 'typeorm'; +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; -export class CreateSavingsTemplatesMilestonesComparisonAndAutoDeposit1792000000000 - implements MigrationInterface -{ +export class CreateSavingsTemplatesMilestonesComparisonAndAutoDeposit1792000000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ @@ -34,7 +27,12 @@ export class CreateSavingsTemplatesMilestonesComparisonAndAutoDeposit17920000000 }, { name: 'icon', type: 'varchar', length: '120', isNullable: false }, { name: 'metadata', type: 'jsonb', isNullable: true }, - { name: 'isActive', type: 'boolean', default: true, isNullable: false }, + { + name: 'isActive', + type: 'boolean', + default: true, + isNullable: false, + }, { name: 'createdAt', type: 'timestamp', default: 'now()' }, { name: 'updatedAt', type: 'timestamp', default: 'now()' }, ], @@ -73,7 +71,10 @@ export class CreateSavingsTemplatesMilestonesComparisonAndAutoDeposit17920000000 await queryRunner.createIndex( 'savings_goal_template_usage', - new TableIndex({ name: 'IDX_SAVINGS_TEMPLATE_USAGE_TEMPLATE', columnNames: ['templateId'] }), + new TableIndex({ + name: 'IDX_SAVINGS_TEMPLATE_USAGE_TEMPLATE', + columnNames: ['templateId'], + }), ); await queryRunner.createTable( @@ -111,7 +112,12 @@ export class CreateSavingsTemplatesMilestonesComparisonAndAutoDeposit17920000000 isNullable: true, }, { name: 'bonusPoints', type: 'int', default: 0, isNullable: false }, - { name: 'shareCode', type: 'varchar', length: '120', isNullable: true }, + { + name: 'shareCode', + type: 'varchar', + length: '120', + isNullable: true, + }, { name: 'metadata', type: 'jsonb', isNullable: true }, { name: 'createdAt', type: 'timestamp', default: 'now()' }, ], diff --git a/backend/src/modules/admin-analytics/admin-analytics.controller.ts b/backend/src/modules/admin-analytics/admin-analytics.controller.ts index a21fd13cf..131328695 100644 --- a/backend/src/modules/admin-analytics/admin-analytics.controller.ts +++ b/backend/src/modules/admin-analytics/admin-analytics.controller.ts @@ -11,7 +11,10 @@ import { AnalyticsOverviewDto } from './dto/analytics-overview.dto'; import { RolesGuard } from '../../common/guards/roles.guard'; import { Roles } from '../../common/decorators/roles.decorator'; import { Role } from '../../common/enums/role.enum'; -import { DateRangeFilterDto, DateRange } from '../admin/dto/admin-analytics.dto'; +import { + DateRangeFilterDto, + DateRange, +} from '../admin/dto/admin-analytics.dto'; @ApiTags('admin/analytics') @Controller('admin/analytics') @@ -49,10 +52,18 @@ export class AdminAnalyticsController { @Roles(Role.ADMIN) @ApiBearerAuth() @ApiOperation({ summary: 'Get user growth, retention, churn metrics' }) - @ApiQuery({ name: 'range', required: false, enum: ['7d', '30d', '90d', '365d', 'custom'] }) + @ApiQuery({ + name: 'range', + required: false, + enum: ['7d', '30d', '90d', '365d', 'custom'], + }) @ApiQuery({ name: 'fromDate', required: false, type: String }) @ApiQuery({ name: 'toDate', required: false, type: String }) - @ApiQuery({ name: 'compareTo', required: false, enum: ['previous_period', 'same_period_last_year'] }) + @ApiQuery({ + name: 'compareTo', + required: false, + enum: ['previous_period', 'same_period_last_year'], + }) @ApiResponse({ status: 200, description: 'User analytics' }) async getUserAnalytics(@Query() filter: DateRangeFilterDto) { return await this.analyticsService.getUserAnalytics(filter); @@ -62,7 +73,11 @@ export class AdminAnalyticsController { @Roles(Role.ADMIN) @ApiBearerAuth() @ApiOperation({ summary: 'Get fee collection and projections' }) - @ApiQuery({ name: 'range', required: false, enum: ['7d', '30d', '90d', '365d', 'custom'] }) + @ApiQuery({ + name: 'range', + required: false, + enum: ['7d', '30d', '90d', '365d', 'custom'], + }) @ApiQuery({ name: 'fromDate', required: false, type: String }) @ApiQuery({ name: 'toDate', required: false, type: String }) @ApiResponse({ status: 200, description: 'Revenue analytics' }) @@ -74,7 +89,11 @@ export class AdminAnalyticsController { @Roles(Role.ADMIN) @ApiBearerAuth() @ApiOperation({ summary: 'Get TVL, APY distribution, product performance' }) - @ApiQuery({ name: 'range', required: false, enum: ['7d', '30d', '90d', '365d', 'custom'] }) + @ApiQuery({ + name: 'range', + required: false, + enum: ['7d', '30d', '90d', '365d', 'custom'], + }) @ApiQuery({ name: 'fromDate', required: false, type: String }) @ApiQuery({ name: 'toDate', required: false, type: String }) @ApiResponse({ status: 200, description: 'Savings analytics' }) @@ -86,7 +105,11 @@ export class AdminAnalyticsController { @Roles(Role.ADMIN) @ApiBearerAuth() @ApiOperation({ summary: 'Get transaction volume trends' }) - @ApiQuery({ name: 'range', required: false, enum: ['7d', '30d', '90d', '365d', 'custom'] }) + @ApiQuery({ + name: 'range', + required: false, + enum: ['7d', '30d', '90d', '365d', 'custom'], + }) @ApiQuery({ name: 'fromDate', required: false, type: String }) @ApiQuery({ name: 'toDate', required: false, type: String }) @ApiResponse({ status: 200, description: 'Transaction analytics' }) diff --git a/backend/src/modules/admin-analytics/admin-analytics.service.spec.ts b/backend/src/modules/admin-analytics/admin-analytics.service.spec.ts index 47361adf9..a9c775e39 100644 --- a/backend/src/modules/admin-analytics/admin-analytics.service.spec.ts +++ b/backend/src/modules/admin-analytics/admin-analytics.service.spec.ts @@ -7,6 +7,9 @@ import { SavingsProduct } from '../savings/entities/savings-product.entity'; import { ProtocolMetrics } from './entities/protocol-metrics.entity'; import { OracleService } from './services/oracle.service'; import { SavingsService } from '../blockchain/savings.service'; +import { User } from '../user/entities/user.entity'; +import { UserSubscription } from '../savings/entities/user-subscription.entity'; +import { Transaction } from '../transactions/entities/transaction.entity'; describe('AdminAnalyticsService', () => { let service: AdminAnalyticsService; @@ -32,6 +35,47 @@ describe('AdminAnalyticsService', () => { save: jest.fn(), }; + const mockUserRepository = { + count: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + })), + }; + + const mockSubscriptionRepository = { + count: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + leftJoinAndSelect: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + getRawOne: jest.fn().mockResolvedValue({ total: '0' }), + })), + }; + + const mockTransactionRepository = { + count: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + getRawOne: jest.fn().mockResolvedValue({ total: '0' }), + })), + }; + const mockOracleService = { getAssetPrice: jest.fn(), }; @@ -60,6 +104,18 @@ describe('AdminAnalyticsService', () => { provide: getRepositoryToken(ProtocolMetrics), useValue: mockProtocolMetricsRepository, }, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + { + provide: getRepositoryToken(UserSubscription), + useValue: mockSubscriptionRepository, + }, + { + provide: getRepositoryToken(Transaction), + useValue: mockTransactionRepository, + }, { provide: OracleService, useValue: mockOracleService, diff --git a/backend/src/modules/admin-analytics/admin-analytics.service.ts b/backend/src/modules/admin-analytics/admin-analytics.service.ts index 3a0aaf757..fa79f74cd 100644 --- a/backend/src/modules/admin-analytics/admin-analytics.service.ts +++ b/backend/src/modules/admin-analytics/admin-analytics.service.ts @@ -2,7 +2,6 @@ import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; import { Cron } from '@nestjs/schedule'; -import { Cacheable } from 'typejs-cacheable'; import { MedicalClaim, ClaimStatus, @@ -14,9 +13,29 @@ import { ProtocolMetrics } from './entities/protocol-metrics.entity'; import { OracleService } from './services/oracle.service'; import { SavingsService } from '../blockchain/savings.service'; import { User } from '../user/entities/user.entity'; -import { UserSubscription, SubscriptionStatus } from '../savings/entities/user-subscription.entity'; -import { Transaction, TxType, TxStatus } from '../transactions/entities/transaction.entity'; -import { DateRangeFilterDto } from '../admin/dto/admin-analytics.dto'; +import { + UserSubscription, + SubscriptionStatus, +} from '../savings/entities/user-subscription.entity'; +import { + Transaction, + TxType, + TxStatus, +} from '../transactions/entities/transaction.entity'; +import { + DateRange, + DateRangeFilterDto, +} from '../admin/dto/admin-analytics.dto'; + +function Cacheable(_options: { cacheable: boolean; ttl: number }) { + return function ( + _target: object, + _propertyKey: string, + descriptor: PropertyDescriptor, + ) { + return descriptor; + }; +} @Injectable() export class AdminAnalyticsService { @@ -186,7 +205,10 @@ export class AdminAnalyticsService { /** * Calculate date range from filter */ - private calculateDateRange(filter: DateRangeFilterDto): { fromDate: Date; toDate: Date } { + private calculateDateRange(filter: DateRangeFilterDto): { + fromDate: Date; + toDate: Date; + } { const toDate = filter.toDate ? new Date(filter.toDate) : new Date(); let fromDate: Date; @@ -194,16 +216,16 @@ export class AdminAnalyticsService { fromDate = new Date(filter.fromDate); } else { switch (filter.range) { - case '7d': + case DateRange.LAST_7_DAYS: fromDate = new Date(toDate.getTime() - 7 * 24 * 60 * 60 * 1000); break; - case '30d': + case DateRange.LAST_30_DAYS: fromDate = new Date(toDate.getTime() - 30 * 24 * 60 * 60 * 1000); break; - case '90d': + case DateRange.LAST_90_DAYS: fromDate = new Date(toDate.getTime() - 90 * 24 * 60 * 60 * 1000); break; - case '365d': + case DateRange.LAST_365_DAYS: fromDate = new Date(toDate.getTime() - 365 * 24 * 60 * 60 * 1000); break; default: @@ -244,14 +266,20 @@ export class AdminAnalyticsService { this.getTotalValueLocked(), this.getMonthlyRevenue(), this.transactionRepository.count(), - this.subscriptionRepository.count({ where: { status: SubscriptionStatus.ACTIVE } }), + this.subscriptionRepository.count({ + where: { status: SubscriptionStatus.ACTIVE }, + }), this.claimRepository.count({ where: { status: ClaimStatus.PENDING } }), this.disputeRepository.count({ - where: [{ status: DisputeStatus.OPEN }, { status: DisputeStatus.IN_PROGRESS }], + where: [ + { status: DisputeStatus.OPEN }, + { status: DisputeStatus.IN_PROGRESS }, + ], }), ]); - const avgSavingsPerUser = totalUsers > 0 ? totalValueLocked / totalUsers : 0; + const avgSavingsPerUser = + totalUsers > 0 ? totalValueLocked / totalUsers : 0; return { totalUsers, @@ -273,7 +301,9 @@ export class AdminAnalyticsService { const result = await this.subscriptionRepository .createQueryBuilder('subscription') .select('SUM(subscription.amount)', 'total') - .where('subscription.status = :status', { status: SubscriptionStatus.ACTIVE }) + .where('subscription.status = :status', { + status: SubscriptionStatus.ACTIVE, + }) .getRawOne(); return parseFloat(result?.total || '0'); @@ -317,7 +347,14 @@ export class AdminAnalyticsService { }> { const { fromDate, toDate } = this.calculateDateRange(filter); - const [totalUsers, newUsers, activeUsers, lastPeriodUsers, usersByTier, usersByKycStatus] = await Promise.all([ + const [ + totalUsers, + newUsers, + activeUsers, + lastPeriodUsers, + usersByTier, + usersByKycStatus, + ] = await Promise.all([ this.userRepository.count(), this.userRepository.count({ where: { @@ -350,11 +387,12 @@ export class AdminAnalyticsService { // Calculate churn (users who haven't logged in during the period) const churnedUsers = totalUsers - activeUsers; - + // Calculate rates const retentionRate = totalUsers > 0 ? (activeUsers / totalUsers) * 100 : 0; const churnRate = totalUsers > 0 ? (churnedUsers / totalUsers) * 100 : 0; - const growthRate = lastPeriodUsers > 0 ? ((newUsers - 0) / lastPeriodUsers) * 100 : 0; + const growthRate = + lastPeriodUsers > 0 ? ((newUsers - 0) / lastPeriodUsers) * 100 : 0; // Format tier distribution const tierDistribution: Record = {}; @@ -388,7 +426,10 @@ export class AdminAnalyticsService { /** * Get user growth trend over time */ - private async getUserGrowthTrend(fromDate: Date, toDate: Date): Promise<{ date: string; count: number }[]> { + private async getUserGrowthTrend( + fromDate: Date, + toDate: Date, + ): Promise<{ date: string; count: number }[]> { const users = await this.userRepository .createQueryBuilder('user') .select('DATE(user.createdAt)', 'date') @@ -427,8 +468,10 @@ export class AdminAnalyticsService { .orderBy('date', 'ASC') .getRawMany(); - const totalRevenue = revenueData.reduce((sum, r) => sum + parseFloat(r.total || '0'), 0) * 0.01; - + const totalRevenue = + revenueData.reduce((sum, r) => sum + parseFloat(r.total || '0'), 0) * + 0.01; + // Get current month revenue const startOfMonth = new Date(); startOfMonth.setDate(1); @@ -464,11 +507,13 @@ export class AdminAnalyticsService { })); // Simple projection (next 3 months based on average) - const avgMonthly = revenueTrend.length > 0 - ? revenueTrend.reduce((sum, r) => sum + r.amount, 0) / revenueTrend.length - : 0; - - const revenueProjection = []; + const avgMonthly = + revenueTrend.length > 0 + ? revenueTrend.reduce((sum, r) => sum + r.amount, 0) / + revenueTrend.length + : 0; + + const revenueProjection: Array<{ month: string; projected: number }> = []; const currentMonth = new Date(); for (let i = 1; i <= 3; i++) { const projectionDate = new Date(currentMonth); @@ -515,12 +560,15 @@ export class AdminAnalyticsService { // Get subscription counts const [totalSubscriptions, activeSubscriptions] = await Promise.all([ this.subscriptionRepository.count(), - this.subscriptionRepository.count({ where: { status: SubscriptionStatus.ACTIVE } }), + this.subscriptionRepository.count({ + where: { status: SubscriptionStatus.ACTIVE }, + }), ]); // Get average savings per user const totalUsers = await this.userRepository.count(); - const avgSavingsPerUser = totalUsers > 0 ? totalValueLocked / totalUsers : 0; + const avgSavingsPerUser = + totalUsers > 0 ? totalValueLocked / totalUsers : 0; // Get product performance const productPerformanceData = await this.subscriptionRepository @@ -529,10 +577,12 @@ export class AdminAnalyticsService { .select('subscription.productId', 'productId') .addSelect('product.name', 'productName') .addSelect('SUM(CAST(subscription.amount AS DECIMAL))', 'tvl') - .addSelect('product.apy', 'apy') + .addSelect('product.interestRate', 'apy') .addSelect('COUNT(*)', 'subscriptionCount') - .where('subscription.status = :status', { status: SubscriptionStatus.ACTIVE }) - .groupBy('subscription.productId, product.name, product.apy') + .where('subscription.status = :status', { + status: SubscriptionStatus.ACTIVE, + }) + .groupBy('subscription.productId, product.name, product.interestRate') .getRawMany(); const productPerformance = productPerformanceData.map((p) => ({ @@ -544,12 +594,35 @@ export class AdminAnalyticsService { })); // APY distribution - const products = await this.savingsProductRepository.find({ where: { isActive: true } }); + const products = await this.savingsProductRepository.find({ + where: { isActive: true }, + }); const apyDistribution = [ - { range: '0-2%', count: products.filter((p) => p.apy >= 0 && p.apy < 2).length }, - { range: '2-5%', count: products.filter((p) => p.apy >= 2 && p.apy < 5).length }, - { range: '5-10%', count: products.filter((p) => p.apy >= 5 && p.apy < 10).length }, - { range: '10%+', count: products.filter((p) => p.apy >= 10).length }, + { + range: '0-2%', + count: products.filter((p) => { + const rate = Number(p.interestRate); + return rate >= 0 && rate < 2; + }).length, + }, + { + range: '2-5%', + count: products.filter((p) => { + const rate = Number(p.interestRate); + return rate >= 2 && rate < 5; + }).length, + }, + { + range: '5-10%', + count: products.filter((p) => { + const rate = Number(p.interestRate); + return rate >= 5 && rate < 10; + }).length, + }, + { + range: '10%+', + count: products.filter((p) => Number(p.interestRate) >= 10).length, + }, ]; // Savings growth trend (from ProtocolMetrics) @@ -606,7 +679,8 @@ export class AdminAnalyticsService { ]); const totalVolume = parseFloat(volumeResult?.total || '0'); - const avgTransactionSize = totalTransactions > 0 ? totalVolume / totalTransactions : 0; + const avgTransactionSize = + totalTransactions > 0 ? totalVolume / totalTransactions : 0; // Transactions by type const byTypeData = await this.transactionRepository diff --git a/backend/src/modules/admin/admin-audit-logs.controller.ts b/backend/src/modules/admin/admin-audit-logs.controller.ts index e3a1849fe..38d4bdbbf 100644 --- a/backend/src/modules/admin/admin-audit-logs.controller.ts +++ b/backend/src/modules/admin/admin-audit-logs.controller.ts @@ -19,7 +19,10 @@ import { } from '@nestjs/swagger'; import { Response } from 'express'; import { AdminAuditLogsService } from './admin-audit-logs.service'; -import { AuditLogFilterDto, AuditLogExportDto } from './dto/admin-audit-log.dto'; +import { + AuditLogFilterDto, + AuditLogExportDto, +} from './dto/admin-audit-log.dto'; import { AuditLog } from '../../common/entities/audit-log.entity'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../common/guards/roles.guard'; @@ -41,7 +44,10 @@ export class AdminAuditLogsController { description: 'List of audit logs', schema: { properties: { - logs: { type: 'array', items: { $ref: '#/components/schemas/AuditLog' } }, + logs: { + type: 'array', + items: { $ref: '#/components/schemas/AuditLog' }, + }, total: { type: 'number' }, page: { type: 'number' }, limit: { type: 'number' }, @@ -49,8 +55,40 @@ export class AdminAuditLogsController { }, }) @ApiQuery({ name: 'actor', required: false, type: String }) - @ApiQuery({ name: 'action', required: false, enum: ['CREATE', 'UPDATE', 'DELETE', 'READ', 'LOGIN', 'LOGOUT', 'APPROVE', 'REJECT', 'ESCALATE', 'RESOLVE', 'ASSIGN', 'EXPORT'] }) - @ApiQuery({ name: 'resourceType', required: false, enum: ['USER', 'DISPUTE', 'CLAIM', 'SAVINGS', 'TRANSACTION', 'CONFIG', 'KYC', 'NOTIFICATION', 'ADMIN', 'SYSTEM'] }) + @ApiQuery({ + name: 'action', + required: false, + enum: [ + 'CREATE', + 'UPDATE', + 'DELETE', + 'READ', + 'LOGIN', + 'LOGOUT', + 'APPROVE', + 'REJECT', + 'ESCALATE', + 'RESOLVE', + 'ASSIGN', + 'EXPORT', + ], + }) + @ApiQuery({ + name: 'resourceType', + required: false, + enum: [ + 'USER', + 'DISPUTE', + 'CLAIM', + 'SAVINGS', + 'TRANSACTION', + 'CONFIG', + 'KYC', + 'NOTIFICATION', + 'ADMIN', + 'SYSTEM', + ], + }) @ApiQuery({ name: 'resourceId', required: false, type: String }) @ApiQuery({ name: 'fromDate', required: false, type: String }) @ApiQuery({ name: 'toDate', required: false, type: String }) @@ -87,7 +125,11 @@ export class AdminAuditLogsController { @Get(':id') @ApiOperation({ summary: 'Get audit log by ID' }) - @ApiResponse({ status: 200, description: 'Audit log details', type: AuditLog }) + @ApiResponse({ + status: 200, + description: 'Audit log details', + type: AuditLog, + }) @ApiResponse({ status: 404, description: 'Audit log not found' }) async getAuditLog(@Param('id') id: string) { return await this.auditLogsService.findOne(id); @@ -120,13 +162,18 @@ export class AdminAuditLogsController { } @Post('cleanup') - @ApiOperation({ summary: 'Clean up old audit logs based on retention policy' }) + @ApiOperation({ + summary: 'Clean up old audit logs based on retention policy', + }) @ApiResponse({ status: 200, description: 'Number of deleted logs', }) async cleanupOldLogs() { const deletedCount = await this.auditLogsService.cleanupOldLogs(); - return { deletedCount, message: `Successfully cleaned up ${deletedCount} old audit logs` }; + return { + deletedCount, + message: `Successfully cleaned up ${deletedCount} old audit logs`, + }; } } diff --git a/backend/src/modules/admin/admin-audit-logs.service.ts b/backend/src/modules/admin/admin-audit-logs.service.ts index f5cb2ee36..6f38be52c 100644 --- a/backend/src/modules/admin/admin-audit-logs.service.ts +++ b/backend/src/modules/admin/admin-audit-logs.service.ts @@ -1,8 +1,21 @@ import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Between, Like, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; -import { AuditLog, AuditAction, AuditResourceType } from '../../common/entities/audit-log.entity'; -import { AuditLogFilterDto, AuditLogExportDto } from './dto/admin-audit-log.dto'; +import { + Repository, + Between, + Like, + MoreThanOrEqual, + LessThanOrEqual, +} from 'typeorm'; +import { + AuditLog, + AuditAction, + AuditResourceType, +} from '../../common/entities/audit-log.entity'; +import { + AuditLogFilterDto, + AuditLogExportDto, +} from './dto/admin-audit-log.dto'; import { ConfigService } from '@nestjs/config'; @Injectable() @@ -15,13 +28,16 @@ export class AdminAuditLogsService { private readonly auditLogRepository: Repository, private readonly configService: ConfigService, ) { - this.RETENTION_DAYS = this.configService.get('audit.retentionDays') || 90; + this.RETENTION_DAYS = + this.configService.get('audit.retentionDays') || 90; } /** * Find all audit logs with filters and pagination */ - async findAll(filters: AuditLogFilterDto): Promise<{ logs: AuditLog[]; total: number; page: number; limit: number }> { + async findAll( + filters: AuditLogFilterDto, + ): Promise<{ logs: AuditLog[]; total: number; page: number; limit: number }> { const page = filters.page || 1; const limit = filters.limit || 20; const skip = (page - 1) * limit; @@ -29,7 +45,9 @@ export class AdminAuditLogsService { const query = this.auditLogRepository.createQueryBuilder('auditLog'); if (filters.actor) { - query.andWhere('auditLog.actor LIKE :actor', { actor: `%${filters.actor}%` }); + query.andWhere('auditLog.actor LIKE :actor', { + actor: `%${filters.actor}%`, + }); } if (filters.action) { @@ -37,19 +55,27 @@ export class AdminAuditLogsService { } if (filters.resourceType) { - query.andWhere('auditLog.resourceType = :resourceType', { resourceType: filters.resourceType }); + query.andWhere('auditLog.resourceType = :resourceType', { + resourceType: filters.resourceType, + }); } if (filters.resourceId) { - query.andWhere('auditLog.resourceId = :resourceId', { resourceId: filters.resourceId }); + query.andWhere('auditLog.resourceId = :resourceId', { + resourceId: filters.resourceId, + }); } if (filters.fromDate) { - query.andWhere('auditLog.timestamp >= :fromDate', { fromDate: filters.fromDate }); + query.andWhere('auditLog.timestamp >= :fromDate', { + fromDate: filters.fromDate, + }); } if (filters.toDate) { - query.andWhere('auditLog.timestamp <= :toDate', { toDate: filters.toDate }); + query.andWhere('auditLog.timestamp <= :toDate', { + toDate: filters.toDate, + }); } query.orderBy('auditLog.timestamp', 'DESC'); @@ -83,11 +109,16 @@ export class AdminAuditLogsService { /** * Export audit logs to CSV or JSON format */ - async exportLogs(filters: AuditLogExportDto, format: 'csv' | 'json' = 'csv'): Promise { + async exportLogs( + filters: AuditLogExportDto, + format: 'csv' | 'json' = 'csv', + ): Promise { const query = this.auditLogRepository.createQueryBuilder('auditLog'); if (filters.actor) { - query.andWhere('auditLog.actor LIKE :actor', { actor: `%${filters.actor}%` }); + query.andWhere('auditLog.actor LIKE :actor', { + actor: `%${filters.actor}%`, + }); } if (filters.action) { @@ -95,19 +126,27 @@ export class AdminAuditLogsService { } if (filters.resourceType) { - query.andWhere('auditLog.resourceType = :resourceType', { resourceType: filters.resourceType }); + query.andWhere('auditLog.resourceType = :resourceType', { + resourceType: filters.resourceType, + }); } if (filters.resourceId) { - query.andWhere('auditLog.resourceId = :resourceId', { resourceId: filters.resourceId }); + query.andWhere('auditLog.resourceId = :resourceId', { + resourceId: filters.resourceId, + }); } if (filters.fromDate) { - query.andWhere('auditLog.timestamp >= :fromDate', { fromDate: filters.fromDate }); + query.andWhere('auditLog.timestamp >= :fromDate', { + fromDate: filters.fromDate, + }); } if (filters.toDate) { - query.andWhere('auditLog.timestamp <= :toDate', { toDate: filters.toDate }); + query.andWhere('auditLog.timestamp <= :toDate', { + toDate: filters.toDate, + }); } query.orderBy('auditLog.timestamp', 'DESC'); @@ -159,8 +198,12 @@ export class AdminAuditLogsService { log.ipAddress || '', log.userAgent ? `"${log.userAgent.replace(/"/g, '""')}"` : '', log.description ? `"${log.description.replace(/"/g, '""')}"` : '', - log.previousValue ? `"${JSON.stringify(log.previousValue).replace(/"/g, '""')}"` : '', - log.newValue ? `"${JSON.stringify(log.newValue).replace(/"/g, '""')}"` : '', + log.previousValue + ? `"${JSON.stringify(log.previousValue).replace(/"/g, '""')}"` + : '', + log.newValue + ? `"${JSON.stringify(log.newValue).replace(/"/g, '""')}"` + : '', ]; csvRows.push(row.join(',')); } @@ -171,7 +214,10 @@ export class AdminAuditLogsService { /** * Get audit log statistics */ - async getStats(fromDate?: string, toDate?: string): Promise<{ + async getStats( + fromDate?: string, + toDate?: string, + ): Promise<{ totalLogs: number; byAction: Record; byResourceType: Record; @@ -200,7 +246,8 @@ export class AdminAuditLogsService { // Count by resource type const byResourceType: Record = {}; for (const log of logs) { - byResourceType[log.resourceType] = (byResourceType[log.resourceType] || 0) + 1; + byResourceType[log.resourceType] = + (byResourceType[log.resourceType] || 0) + 1; } // Top actors @@ -244,7 +291,9 @@ export class AdminAuditLogsService { .execute(); const deletedCount = result.affected || 0; - this.logger.log(`Cleaned up ${deletedCount} audit logs older than ${this.RETENTION_DAYS} days`); + this.logger.log( + `Cleaned up ${deletedCount} audit logs older than ${this.RETENTION_DAYS} days`, + ); return deletedCount; } @@ -255,7 +304,8 @@ export class AdminAuditLogsService { getRetentionPolicy(): { retentionDays: number; configured: boolean } { return { retentionDays: this.RETENTION_DAYS, - configured: this.configService.get('audit.retentionDays') !== undefined, + configured: + this.configService.get('audit.retentionDays') !== undefined, }; } } diff --git a/backend/src/modules/admin/admin-disputes.controller.ts b/backend/src/modules/admin/admin-disputes.controller.ts index 4ff692f46..4c0de3286 100644 --- a/backend/src/modules/admin/admin-disputes.controller.ts +++ b/backend/src/modules/admin/admin-disputes.controller.ts @@ -47,13 +47,31 @@ export class AdminDisputesController { description: 'List of disputes', schema: { properties: { - disputes: { type: 'array', items: { $ref: '#/components/schemas/Dispute' } }, + disputes: { + type: 'array', + items: { $ref: '#/components/schemas/Dispute' }, + }, total: { type: 'number' }, }, }, }) - @ApiQuery({ name: 'status', required: false, enum: ['OPEN', 'IN_PROGRESS', 'UNDER_REVIEW', 'RESOLVED', 'CLOSED', 'ESCALATED'] }) - @ApiQuery({ name: 'priority', required: false, enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] }) + @ApiQuery({ + name: 'status', + required: false, + enum: [ + 'OPEN', + 'IN_PROGRESS', + 'UNDER_REVIEW', + 'RESOLVED', + 'CLOSED', + 'ESCALATED', + ], + }) + @ApiQuery({ + name: 'priority', + required: false, + enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'], + }) @ApiQuery({ name: 'assignedTo', required: false, type: String }) @ApiQuery({ name: 'fromDate', required: false, type: String }) @ApiQuery({ name: 'toDate', required: false, type: String }) @@ -102,7 +120,12 @@ export class AdminDisputesController { @Headers('x-forwarded-for') forwardedFor?: string, ) { const ipAddress = forwardedFor || req?.ip; - return await this.adminDisputesService.assignDispute(id, dto, req.user?.id || 'admin', ipAddress); + return await this.adminDisputesService.assignDispute( + id, + dto, + req.user?.id || 'admin', + ipAddress, + ); } @Post(':id/resolve') @@ -116,7 +139,12 @@ export class AdminDisputesController { @Headers('x-forwarded-for') forwardedFor?: string, ) { const ipAddress = forwardedFor || req?.ip; - return await this.adminDisputesService.resolveDispute(id, dto, req.user?.id || 'admin', ipAddress); + return await this.adminDisputesService.resolveDispute( + id, + dto, + req.user?.id || 'admin', + ipAddress, + ); } @Post(':id/escalate') @@ -130,7 +158,12 @@ export class AdminDisputesController { @Headers('x-forwarded-for') forwardedFor?: string, ) { const ipAddress = forwardedFor || req?.ip; - return await this.adminDisputesService.escalateDispute(id, dto, req.user?.id || 'admin', ipAddress); + return await this.adminDisputesService.escalateDispute( + id, + dto, + req.user?.id || 'admin', + ipAddress, + ); } @Post(':id/evidence') @@ -144,7 +177,12 @@ export class AdminDisputesController { @Headers('x-forwarded-for') forwardedFor?: string, ) { const ipAddress = forwardedFor || req?.ip; - return await this.adminDisputesService.addEvidence(id, dto, req.user?.id || 'admin', ipAddress); + return await this.adminDisputesService.addEvidence( + id, + dto, + req.user?.id || 'admin', + ipAddress, + ); } @Patch(':id') @@ -158,6 +196,11 @@ export class AdminDisputesController { @Headers('x-forwarded-for') forwardedFor?: string, ) { const ipAddress = forwardedFor || req?.ip; - return await this.adminDisputesService.updateDispute(id, dto, req.user?.id || 'admin', ipAddress); + return await this.adminDisputesService.updateDispute( + id, + dto, + req.user?.id || 'admin', + ipAddress, + ); } } diff --git a/backend/src/modules/admin/admin-disputes.service.ts b/backend/src/modules/admin/admin-disputes.service.ts index 4a1527977..3d8693e91 100644 --- a/backend/src/modules/admin/admin-disputes.service.ts +++ b/backend/src/modules/admin/admin-disputes.service.ts @@ -1,7 +1,12 @@ import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Dispute, DisputeStatus, DisputePriority, DisputeTimeline } from '../disputes/entities/dispute.entity'; +import { + Dispute, + DisputeStatus, + DisputePriority, + DisputeTimeline, +} from '../disputes/entities/dispute.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { DisputeFilterDto, @@ -29,8 +34,11 @@ export class AdminDisputesService { /** * Find all disputes with optional filters */ - async findAll(filters: DisputeFilterDto): Promise<{ disputes: Dispute[]; total: number }> { - const query = this.disputeRepository.createQueryBuilder('dispute') + async findAll( + filters: DisputeFilterDto, + ): Promise<{ disputes: Dispute[]; total: number }> { + const query = this.disputeRepository + .createQueryBuilder('dispute') .leftJoinAndSelect('dispute.claim', 'claim') .leftJoinAndSelect('dispute.messages', 'messages') .leftJoinAndSelect('dispute.timeline', 'timeline'); @@ -40,19 +48,27 @@ export class AdminDisputesService { } if (filters.priority) { - query.andWhere('dispute.priority = :priority', { priority: filters.priority }); + query.andWhere('dispute.priority = :priority', { + priority: filters.priority, + }); } if (filters.assignedTo) { - query.andWhere('dispute.assignedTo = :assignedTo', { assignedTo: filters.assignedTo }); + query.andWhere('dispute.assignedTo = :assignedTo', { + assignedTo: filters.assignedTo, + }); } if (filters.fromDate) { - query.andWhere('dispute.createdAt >= :fromDate', { fromDate: filters.fromDate }); + query.andWhere('dispute.createdAt >= :fromDate', { + fromDate: filters.fromDate, + }); } if (filters.toDate) { - query.andWhere('dispute.createdAt <= :toDate', { toDate: filters.toDate }); + query.andWhere('dispute.createdAt <= :toDate', { + toDate: filters.toDate, + }); } query.orderBy('dispute.createdAt', 'DESC'); @@ -88,7 +104,12 @@ export class AdminDisputesService { /** * Assign a dispute to an admin */ - async assignDispute(id: string, dto: AssignDisputeDto, adminId: string, ipAddress?: string): Promise { + async assignDispute( + id: string, + dto: AssignDisputeDto, + adminId: string, + ipAddress?: string, + ): Promise { const dispute = await this.findOne(id); const previousState = { assignedTo: dispute.assignedTo, @@ -97,23 +118,35 @@ export class AdminDisputesService { dispute.assignedTo = dto.assignedTo; dispute.assignedAt = new Date(); - dispute.status = dispute.status === DisputeStatus.OPEN - ? DisputeStatus.IN_PROGRESS - : dispute.status; + dispute.status = + dispute.status === DisputeStatus.OPEN + ? DisputeStatus.IN_PROGRESS + : dispute.status; const savedDispute = await this.disputeRepository.save(dispute); // Create timeline entry - await this.createTimelineEntry(dispute, 'ASSIGN', adminId, 'Dispute assigned to admin', previousState, { - assignedTo: dto.assignedTo, - assignedAt: dispute.assignedAt, - }, ipAddress); + await this.createTimelineEntry( + dispute, + 'ASSIGN', + adminId, + 'Dispute assigned to admin', + previousState, + { + assignedTo: dto.assignedTo, + assignedAt: dispute.assignedAt, + }, + ipAddress, + ); // Notify user await this.notifyUserOnStatusChange(dispute, 'assigned'); // Emit event - this.eventEmitter.emit('dispute.assigned', { disputeId: id, assignedTo: dto.assignedTo }); + this.eventEmitter.emit('dispute.assigned', { + disputeId: id, + assignedTo: dto.assignedTo, + }); return savedDispute; } @@ -121,7 +154,12 @@ export class AdminDisputesService { /** * Resolve a dispute */ - async resolveDispute(id: string, dto: ResolveDisputeDto, adminId: string, ipAddress?: string): Promise { + async resolveDispute( + id: string, + dto: ResolveDisputeDto, + adminId: string, + ipAddress?: string, + ): Promise { const dispute = await this.findOne(id); const previousState = { status: dispute.status, @@ -138,18 +176,29 @@ export class AdminDisputesService { const savedDispute = await this.disputeRepository.save(dispute); // Create timeline entry - await this.createTimelineEntry(dispute, 'RESOLVE', adminId, 'Dispute resolved', previousState, { - resolution: dto.resolution, - status: dispute.status, - resolvedAt: dispute.resolvedAt, - resolvedBy: adminId, - }, ipAddress); + await this.createTimelineEntry( + dispute, + 'RESOLVE', + adminId, + 'Dispute resolved', + previousState, + { + resolution: dto.resolution, + status: dispute.status, + resolvedAt: dispute.resolvedAt, + resolvedBy: adminId, + }, + ipAddress, + ); // Notify user await this.notifyUserOnStatusChange(dispute, 'resolved'); // Emit event - this.eventEmitter.emit('dispute.resolved', { disputeId: id, resolution: dto.resolution }); + this.eventEmitter.emit('dispute.resolved', { + disputeId: id, + resolution: dto.resolution, + }); return savedDispute; } @@ -157,7 +206,12 @@ export class AdminDisputesService { /** * Escalate a dispute to a senior admin */ - async escalateDispute(id: string, dto: EscalateDisputeDto, adminId: string, ipAddress?: string): Promise { + async escalateDispute( + id: string, + dto: EscalateDisputeDto, + adminId: string, + ipAddress?: string, + ): Promise { const dispute = await this.findOne(id); const previousState = { escalatedTo: dispute.escalatedTo, @@ -172,19 +226,30 @@ export class AdminDisputesService { const savedDispute = await this.disputeRepository.save(dispute); // Create timeline entry - await this.createTimelineEntry(dispute, 'ESCALATE', adminId, - dto.reason ? `Dispute escalated: ${dto.reason}` : 'Dispute escalated to senior admin', - previousState, { + await this.createTimelineEntry( + dispute, + 'ESCALATE', + adminId, + dto.reason + ? `Dispute escalated: ${dto.reason}` + : 'Dispute escalated to senior admin', + previousState, + { escalatedTo: dto.escalatedTo, escalatedAt: dispute.escalatedAt, status: DisputeStatus.ESCALATED, - }, ipAddress); + }, + ipAddress, + ); // Notify user await this.notifyUserOnStatusChange(dispute, 'escalated'); // Emit event - this.eventEmitter.emit('dispute.escalated', { disputeId: id, escalatedTo: dto.escalatedTo }); + this.eventEmitter.emit('dispute.escalated', { + disputeId: id, + escalatedTo: dto.escalatedTo, + }); return savedDispute; } @@ -192,7 +257,12 @@ export class AdminDisputesService { /** * Add evidence/document to a dispute */ - async addEvidence(id: string, dto: AddEvidenceDto, adminId: string, ipAddress?: string): Promise { + async addEvidence( + id: string, + dto: AddEvidenceDto, + adminId: string, + ipAddress?: string, + ): Promise { const dispute = await this.findOne(id); const previousState = { evidence: dispute.evidence || [] }; @@ -211,12 +281,23 @@ export class AdminDisputesService { const savedDispute = await this.disputeRepository.save(dispute); // Create timeline entry - await this.createTimelineEntry(dispute, 'EVIDENCE_ADD', adminId, `Evidence added: ${dto.name}`, previousState, { - evidence: dispute.evidence, - }, ipAddress); + await this.createTimelineEntry( + dispute, + 'EVIDENCE_ADD', + adminId, + `Evidence added: ${dto.name}`, + previousState, + { + evidence: dispute.evidence, + }, + ipAddress, + ); // Emit event - this.eventEmitter.emit('dispute.evidence.added', { disputeId: id, evidence: newEvidence }); + this.eventEmitter.emit('dispute.evidence.added', { + disputeId: id, + evidence: newEvidence, + }); return savedDispute; } @@ -224,7 +305,12 @@ export class AdminDisputesService { /** * Update dispute status/priority */ - async updateDispute(id: string, dto: UpdateDisputeDto, adminId: string, ipAddress?: string): Promise { + async updateDispute( + id: string, + dto: UpdateDisputeDto, + adminId: string, + ipAddress?: string, + ): Promise { const dispute = await this.findOne(id); const previousState = { status: dispute.status, @@ -246,11 +332,19 @@ export class AdminDisputesService { const savedDispute = await this.disputeRepository.save(dispute); // Create timeline entry - await this.createTimelineEntry(dispute, 'UPDATE', adminId, 'Dispute updated', previousState, { - status: dispute.status, - priority: dispute.priority, - assignedTo: dispute.assignedTo, - }, ipAddress); + await this.createTimelineEntry( + dispute, + 'UPDATE', + adminId, + 'Dispute updated', + previousState, + { + status: dispute.status, + priority: dispute.priority, + assignedTo: dispute.assignedTo, + }, + ipAddress, + ); // Notify user if status changed if (dto.status && dto.status !== previousState.status) { @@ -274,16 +368,40 @@ export class AdminDisputesService { escalated: number; byPriority: Record; }> { - const [total, open, inProgress, resolved, escalated, low, medium, high, critical] = await Promise.all([ + const [ + total, + open, + inProgress, + resolved, + escalated, + low, + medium, + high, + critical, + ] = await Promise.all([ this.disputeRepository.count(), this.disputeRepository.count({ where: { status: DisputeStatus.OPEN } }), - this.disputeRepository.count({ where: { status: DisputeStatus.IN_PROGRESS } }), - this.disputeRepository.count({ where: { status: DisputeStatus.RESOLVED } }), - this.disputeRepository.count({ where: { status: DisputeStatus.ESCALATED } }), - this.disputeRepository.count({ where: { priority: DisputePriority.LOW } }), - this.disputeRepository.count({ where: { priority: DisputePriority.MEDIUM } }), - this.disputeRepository.count({ where: { priority: DisputePriority.HIGH } }), - this.disputeRepository.count({ where: { priority: DisputePriority.CRITICAL } }), + this.disputeRepository.count({ + where: { status: DisputeStatus.IN_PROGRESS }, + }), + this.disputeRepository.count({ + where: { status: DisputeStatus.RESOLVED }, + }), + this.disputeRepository.count({ + where: { status: DisputeStatus.ESCALATED }, + }), + this.disputeRepository.count({ + where: { priority: DisputePriority.LOW }, + }), + this.disputeRepository.count({ + where: { priority: DisputePriority.MEDIUM }, + }), + this.disputeRepository.count({ + where: { priority: DisputePriority.HIGH }, + }), + this.disputeRepository.count({ + where: { priority: DisputePriority.CRITICAL }, + }), ]); return { @@ -329,7 +447,10 @@ export class AdminDisputesService { /** * Notify user on status change */ - private async notifyUserOnStatusChange(dispute: Dispute, changeType: string): Promise { + private async notifyUserOnStatusChange( + dispute: Dispute, + changeType: string, + ): Promise { try { let title = 'Dispute Update'; let message = ''; @@ -361,7 +482,9 @@ export class AdminDisputesService { metadata: { disputeId: dispute.id, changeType }, }); } catch (error) { - this.logger.error(`Failed to notify user on status change: ${error.message}`); + this.logger.error( + `Failed to notify user on status change: ${error.message}`, + ); } } } diff --git a/backend/src/modules/admin/admin-notifications.controller.ts b/backend/src/modules/admin/admin-notifications.controller.ts index f0330a77e..77bd1225d 100644 --- a/backend/src/modules/admin/admin-notifications.controller.ts +++ b/backend/src/modules/admin/admin-notifications.controller.ts @@ -33,10 +33,14 @@ import { Role } from '../../common/enums/role.enum'; @Roles(Role.ADMIN) @ApiBearerAuth() export class AdminNotificationsController { - constructor(private readonly notificationsService: AdminNotificationsService) {} + constructor( + private readonly notificationsService: AdminNotificationsService, + ) {} @Post('broadcast') - @ApiOperation({ summary: 'Send broadcast notification to all or targeted users' }) + @ApiOperation({ + summary: 'Send broadcast notification to all or targeted users', + }) @ApiResponse({ status: 200, description: 'Broadcast sent', @@ -95,7 +99,11 @@ export class AdminNotificationsController { @ApiOperation({ summary: 'Get notification broadcast history' }) @ApiQuery({ name: 'fromDate', required: false, type: String }) @ApiQuery({ name: 'toDate', required: false, type: String }) - @ApiQuery({ name: 'channel', required: false, enum: ['EMAIL', 'IN_APP', 'PUSH'] }) + @ApiQuery({ + name: 'channel', + required: false, + enum: ['EMAIL', 'IN_APP', 'PUSH'], + }) @ApiQuery({ name: 'page', required: false, type: Number }) @ApiQuery({ name: 'limit', required: false, type: Number }) @ApiResponse({ status: 200, description: 'Notification history' }) diff --git a/backend/src/modules/admin/admin-notifications.service.ts b/backend/src/modules/admin/admin-notifications.service.ts index abb61ac4c..34cfc59e3 100644 --- a/backend/src/modules/admin/admin-notifications.service.ts +++ b/backend/src/modules/admin/admin-notifications.service.ts @@ -2,9 +2,15 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { Notification } from '../notifications/entities/notification.entity'; +import { + Notification, + NotificationType, +} from '../notifications/entities/notification.entity'; import { User } from '../user/entities/user.entity'; -import { UserSubscription, SubscriptionStatus } from '../savings/entities/user-subscription.entity'; +import { + UserSubscription, + SubscriptionStatus, +} from '../savings/entities/user-subscription.entity'; import { MailService } from '../mail/mail.service'; import { BroadcastNotificationDto, @@ -35,9 +41,11 @@ export class AdminNotificationsService { /** * Send broadcast notification to all users or targeted users */ - async broadcastNotification(dto: BroadcastNotificationDto): Promise { + async broadcastNotification( + dto: BroadcastNotificationDto, + ): Promise { const targetUsers = await this.getTargetUsers(dto.target); - + const delivery: NotificationDeliveryDto = { sent: 0, delivered: 0, @@ -69,7 +77,11 @@ export class AdminNotificationsService { // Send email if (channels.includes(NotificationChannel.EMAIL) && user.email) { - await this.mailService.sendNotificationEmail(user.email, dto.title, dto.message); + await this.mailService.sendRawMail( + user.email, + dto.title, + dto.message, + ); delivery.delivered++; } @@ -78,23 +90,28 @@ export class AdminNotificationsService { // TODO: Implement push notification integration delivery.delivered++; } - } catch (error) { - this.logger.error(`Failed to send notification to user ${user.id}: ${error.message}`); + this.logger.error( + `Failed to send notification to user ${user.id}: ${error.message}`, + ); delivery.failed++; } } - this.logger.log(`Broadcast notification sent: ${delivery.sent} sent, ${delivery.delivered} delivered, ${delivery.failed} failed`); + this.logger.log( + `Broadcast notification sent: ${delivery.sent} sent, ${delivery.delivered} delivered, ${delivery.failed} failed`, + ); return delivery; } /** * Schedule a notification for future delivery */ - async scheduleNotification(dto: ScheduleNotificationDto): Promise<{ scheduleId: string }> { + async scheduleNotification( + dto: ScheduleNotificationDto, + ): Promise<{ scheduleId: string }> { const scheduledAt = new Date(dto.scheduledAt); - + // Create a scheduled notification record const notification = this.notificationRepository.create({ userId: 'SYSTEM', // System-wide @@ -127,11 +144,15 @@ export class AdminNotificationsService { }); if (!notification) { - throw new NotFoundException(`Scheduled notification ${scheduleId} not found`); + throw new NotFoundException( + `Scheduled notification ${scheduleId} not found`, + ); } if (!notification.metadata?.scheduled) { - throw new NotFoundException(`Notification ${scheduleId} is not a scheduled notification`); + throw new NotFoundException( + `Notification ${scheduleId} is not a scheduled notification`, + ); } await this.notificationRepository.delete(scheduleId); @@ -173,19 +194,27 @@ export class AdminNotificationsService { const limit = filter.limit || 20; const skip = (page - 1) * limit; - const query = this.notificationRepository.createQueryBuilder('notification') + const query = this.notificationRepository + .createQueryBuilder('notification') .where('notification.type = :type', { type: ADMIN_BROADCAST_TYPE }) .orderBy('notification.createdAt', 'DESC'); if (filter.fromDate) { - query.andWhere('notification.createdAt >= :fromDate', { fromDate: filter.fromDate }); + query.andWhere('notification.createdAt >= :fromDate', { + fromDate: filter.fromDate, + }); } if (filter.toDate) { - query.andWhere('notification.createdAt <= :toDate', { toDate: filter.toDate }); + query.andWhere('notification.createdAt <= :toDate', { + toDate: filter.toDate, + }); } - const [notifications, total] = await query.skip(skip).take(limit).getManyAndCount(); + const [notifications, total] = await query + .skip(skip) + .take(limit) + .getManyAndCount(); return { notifications, @@ -198,7 +227,9 @@ export class AdminNotificationsService { /** * Get delivery statistics for a notification */ - async getDeliveryStats(notificationId: string): Promise { + async getDeliveryStats( + notificationId: string, + ): Promise { const notification = await this.notificationRepository.findOne({ where: { id: notificationId }, }); @@ -250,7 +281,9 @@ export class AdminNotificationsService { } if (target?.kycStatus && target.kycStatus.length > 0) { - query.andWhere('user.kycStatus IN (:...kycStatus)', { kycStatus: target.kycStatus }); + query.andWhere('user.kycStatus IN (:...kycStatus)', { + kycStatus: target.kycStatus, + }); } if (target?.tiers && target.tiers.length > 0) { @@ -266,14 +299,16 @@ export class AdminNotificationsService { .createQueryBuilder('subscription') .select('subscription.userId', 'userId') .addSelect('SUM(subscription.amount)', 'total') - .where('subscription.status = :status', { status: SubscriptionStatus.ACTIVE }) + .where('subscription.status = :status', { + status: SubscriptionStatus.ACTIVE, + }) .groupBy('subscription.userId') .having( target.minSavings !== undefined && target.maxSavings !== undefined ? 'SUM(subscription.amount) BETWEEN :min AND :max' : target.minSavings !== undefined - ? 'SUM(subscription.amount) >= :min' - : 'SUM(subscription.amount) <= :max', + ? 'SUM(subscription.amount) >= :min' + : 'SUM(subscription.amount) <= :max', { min: target.minSavings, max: target.maxSavings, @@ -298,9 +333,15 @@ export class AdminNotificationsService { const scheduledNotifications = await this.notificationRepository .createQueryBuilder('notification') - .where('notification.metadata->>scheduled = :scheduled', { scheduled: 'true' }) - .andWhere('notification.metadata->>processed = :processed', { processed: 'false' }) - .andWhere('notification.metadata->scheduledAt <= :now', { now: now.toISOString() }) + .where('notification.metadata->>scheduled = :scheduled', { + scheduled: 'true', + }) + .andWhere('notification.metadata->>processed = :processed', { + processed: 'false', + }) + .andWhere('notification.metadata->scheduledAt <= :now', { + now: now.toISOString(), + }) .getMany(); for (const notification of scheduledNotifications) { @@ -308,7 +349,9 @@ export class AdminNotificationsService { const dto: BroadcastNotificationDto = { title: notification.title, message: notification.message, - channels: notification.metadata?.channels || [NotificationChannel.IN_APP], + channels: notification.metadata?.channels || [ + NotificationChannel.IN_APP, + ], target: notification.metadata?.target, }; @@ -320,7 +363,9 @@ export class AdminNotificationsService { this.logger.log(`Processed scheduled notification ${notification.id}`); } catch (error) { - this.logger.error(`Failed to process scheduled notification ${notification.id}: ${error.message}`); + this.logger.error( + `Failed to process scheduled notification ${notification.id}: ${error.message}`, + ); } } } diff --git a/backend/src/modules/admin/admin-savings.controller.ts b/backend/src/modules/admin/admin-savings.controller.ts index 36a1e8cd7..5aab657c4 100644 --- a/backend/src/modules/admin/admin-savings.controller.ts +++ b/backend/src/modules/admin/admin-savings.controller.ts @@ -4,15 +4,11 @@ import { Post, Patch, Body, - Controller, Delete, - Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, - Patch, - Post, Query, UseGuards, } from '@nestjs/common'; @@ -26,8 +22,6 @@ import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../common/guards/roles.guard'; import { Roles } from '../../common/decorators/roles.decorator'; import { Role } from '../../common/enums/role.enum'; -import { CurrentUser } from '../../common/decorators/current-user.decorator'; -import { ProductCapacitySnapshot } from '../savings/savings.service'; import { PageOptionsDto } from '../../common/dto/page-options.dto'; import { CreateProductDto } from '../savings/dto/create-product.dto'; import { UpdateProductDto } from '../savings/dto/update-product.dto'; @@ -82,40 +76,4 @@ export class AdminSavingsController { ) { return this.adminSavingsService.getSubscribers(id, opts); } - - @Post('products/:id/migrations') - @ApiOperation({ - summary: 'Migrate active subscriptions to another product version (admin)', - }) - @ApiResponse({ - status: 200, - description: 'Subscriptions migrated to the target product version', - }) - async migrateProductSubscriptions( - @Param('id') id: string, - @Body() body: { targetProductId: string; subscriptionIds?: string[] }, - @CurrentUser() user: { id: string; email: string }, - ): Promise<{ migratedCount: number; targetProductId: string }> { - const result = await this.savingsService.migrateSubscriptionsToVersion( - id, - body.targetProductId, - user.id, - body.subscriptionIds, - ); - - return { - migratedCount: result.migratedCount, - targetProductId: result.targetProduct.id, - }; - @Get('products/:id/capacity-metrics') - @ApiOperation({ summary: 'Get live capacity utilization metrics (admin)' }) - @ApiResponse({ - status: 200, - description: 'Live capacity metrics', - }) - async getCapacityMetrics( - @Param('id') id: string, - ): Promise { - return await this.savingsService.getProductCapacitySnapshot(id); - } } diff --git a/backend/src/modules/admin/admin.module.ts b/backend/src/modules/admin/admin.module.ts index 6d549988a..9962db7eb 100644 --- a/backend/src/modules/admin/admin.module.ts +++ b/backend/src/modules/admin/admin.module.ts @@ -29,8 +29,6 @@ import { SavingsProduct } from '../savings/entities/savings-product.entity'; import { LedgerTransaction } from '../blockchain/entities/transaction.entity'; import { Transaction } from '../transactions/entities/transaction.entity'; import { Dispute, DisputeTimeline } from '../disputes/entities/dispute.entity'; -import { NotificationsModule } from '../notifications/notifications.module'; -import { EventEmitterModule } from '@nestjs/event-emitter'; import { AuditLog } from '../../common/entities/audit-log.entity'; import { Notification } from '../notifications/entities/notification.entity'; diff --git a/backend/src/modules/admin/dto/admin-analytics.dto.ts b/backend/src/modules/admin/dto/admin-analytics.dto.ts index 43d4badfa..1b4e7d94d 100644 --- a/backend/src/modules/admin/dto/admin-analytics.dto.ts +++ b/backend/src/modules/admin/dto/admin-analytics.dto.ts @@ -1,4 +1,10 @@ -import { IsOptional, IsDateString, IsNumber, IsEnum } from 'class-validator'; +import { + IsOptional, + IsDateString, + IsNumber, + IsEnum, + IsString, +} from 'class-validator'; import { ApiPropertyOptional } from '@nestjs/swagger'; export enum DateRange { @@ -64,5 +70,3 @@ export class TransactionAnalyticsFilterDto extends DateRangeFilterDto { @IsString() transactionType?: string; } - -import { IsString } from 'class-validator'; \ No newline at end of file diff --git a/backend/src/modules/admin/dto/admin-audit-log.dto.ts b/backend/src/modules/admin/dto/admin-audit-log.dto.ts index d1922445c..621656174 100644 --- a/backend/src/modules/admin/dto/admin-audit-log.dto.ts +++ b/backend/src/modules/admin/dto/admin-audit-log.dto.ts @@ -1,6 +1,15 @@ -import { IsString, IsOptional, IsEnum, IsDateString, IsNumber } from 'class-validator'; +import { + IsString, + IsOptional, + IsEnum, + IsDateString, + IsNumber, +} from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { AuditAction, AuditResourceType } from '../../../common/entities/audit-log.entity'; +import { + AuditAction, + AuditResourceType, +} from '../../../common/entities/audit-log.entity'; export class AuditLogFilterDto { @ApiPropertyOptional() @@ -75,7 +84,10 @@ export class AuditLogExportDto { @IsDateString() toDate?: string; - @ApiPropertyOptional({ description: 'Format for export', enum: ['csv', 'json'] }) + @ApiPropertyOptional({ + description: 'Format for export', + enum: ['csv', 'json'], + }) @IsOptional() @IsString() format?: string; diff --git a/backend/src/modules/admin/dto/admin-dispute.dto.ts b/backend/src/modules/admin/dto/admin-dispute.dto.ts index 1be753664..f279debf5 100644 --- a/backend/src/modules/admin/dto/admin-dispute.dto.ts +++ b/backend/src/modules/admin/dto/admin-dispute.dto.ts @@ -1,11 +1,9 @@ -import { - IsString, - IsOptional, - IsEnum, - IsNotEmpty, -} from 'class-validator'; +import { IsString, IsOptional, IsEnum, IsNotEmpty } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { DisputeStatus, DisputePriority } from '../../disputes/entities/dispute.entity'; +import { + DisputeStatus, + DisputePriority, +} from '../../disputes/entities/dispute.entity'; export class DisputeFilterDto { @ApiPropertyOptional({ enum: DisputeStatus }) @@ -47,7 +45,10 @@ export class ResolveDisputeDto { @IsNotEmpty() resolution: string; - @ApiPropertyOptional({ enum: DisputeStatus, description: 'Final status after resolution' }) + @ApiPropertyOptional({ + enum: DisputeStatus, + description: 'Final status after resolution', + }) @IsOptional() @IsEnum(DisputeStatus) status?: DisputeStatus; @@ -76,7 +77,9 @@ export class AddEvidenceDto { @IsNotEmpty() url: string; - @ApiPropertyOptional({ description: 'Type of evidence (e.g., document, image, pdf)' }) + @ApiPropertyOptional({ + description: 'Type of evidence (e.g., document, image, pdf)', + }) @IsOptional() @IsString() type?: string; diff --git a/backend/src/modules/admin/dto/admin-notification.dto.ts b/backend/src/modules/admin/dto/admin-notification.dto.ts index 6f10bd41f..c45226418 100644 --- a/backend/src/modules/admin/dto/admin-notification.dto.ts +++ b/backend/src/modules/admin/dto/admin-notification.dto.ts @@ -1,4 +1,12 @@ -import { IsString, IsOptional, IsEnum, IsBoolean, IsDateString, IsNumber, IsArray } from 'class-validator'; +import { + IsString, + IsOptional, + IsEnum, + IsBoolean, + IsDateString, + IsNumber, + IsArray, +} from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export enum NotificationChannel { @@ -41,17 +49,26 @@ export class NotificationFilterDto { } export class UserTargetDto { - @ApiPropertyOptional({ description: 'Filter by user role', enum: ['USER', 'ADMIN'] }) + @ApiPropertyOptional({ + description: 'Filter by user role', + enum: ['USER', 'ADMIN'], + }) @IsOptional() @IsArray() roles?: string[]; - @ApiPropertyOptional({ description: 'Filter by KYC status', enum: ['NOT_SUBMITTED', 'PENDING', 'APPROVED', 'REJECTED'] }) + @ApiPropertyOptional({ + description: 'Filter by KYC status', + enum: ['NOT_SUBMITTED', 'PENDING', 'APPROVED', 'REJECTED'], + }) @IsOptional() @IsArray() kycStatus?: string[]; - @ApiPropertyOptional({ description: 'Filter by user tier', enum: ['FREE', 'VERIFIED', 'PREMIUM', 'ENTERPRISE'] }) + @ApiPropertyOptional({ + description: 'Filter by user tier', + enum: ['FREE', 'VERIFIED', 'PREMIUM', 'ENTERPRISE'], + }) @IsOptional() @IsArray() tiers?: string[]; @@ -83,12 +100,19 @@ export class BroadcastNotificationDto { @IsNotEmpty() message: string; - @ApiPropertyOptional({ description: 'Notification channels to use', enum: ['EMAIL', 'IN_APP', 'PUSH'], isArray: true }) + @ApiPropertyOptional({ + description: 'Notification channels to use', + enum: ['EMAIL', 'IN_APP', 'PUSH'], + isArray: true, + }) @IsOptional() @IsArray() channels?: NotificationChannel[]; - @ApiPropertyOptional({ description: 'Target specific users or groups', type: UserTargetDto }) + @ApiPropertyOptional({ + description: 'Target specific users or groups', + type: UserTargetDto, + }) @IsOptional() target?: UserTargetDto; } @@ -98,7 +122,9 @@ export class ScheduleNotificationDto extends BroadcastNotificationDto { @IsDateString() scheduledAt: string; - @ApiPropertyOptional({ description: 'Timezone for scheduling (defaults to UTC)' }) + @ApiPropertyOptional({ + description: 'Timezone for scheduling (defaults to UTC)', + }) @IsOptional() @IsString() timezone?: string; @@ -115,11 +141,16 @@ export class PreviewNotificationDto { @IsNotEmpty() message: string; - @ApiPropertyOptional({ description: 'Target users for preview', type: UserTargetDto }) + @ApiPropertyOptional({ + description: 'Target users for preview', + type: UserTargetDto, + }) @IsOptional() target?: UserTargetDto; - @ApiPropertyOptional({ description: 'Number of users to preview (default 5)' }) + @ApiPropertyOptional({ + description: 'Number of users to preview (default 5)', + }) @IsOptional() @IsNumber() previewCount?: number; @@ -139,4 +170,4 @@ export class NotificationDeliveryDto { failed: number; } -import { IsNotEmpty } from 'class-validator'; \ No newline at end of file +import { IsNotEmpty } from 'class-validator'; diff --git a/backend/src/modules/blockchain/balance-sync.service.spec.ts b/backend/src/modules/blockchain/balance-sync.service.spec.ts index 3189b90b1..a48ad1b8b 100644 --- a/backend/src/modules/blockchain/balance-sync.service.spec.ts +++ b/backend/src/modules/blockchain/balance-sync.service.spec.ts @@ -9,8 +9,10 @@ import { ProtocolMetrics } from '../admin-analytics/entities/protocol-metrics.en // Requirements: 1.2, 1.4 -const MOCK_PUBLIC_KEY_A = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'; -const MOCK_PUBLIC_KEY_B = 'GBCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC'; +const MOCK_PUBLIC_KEY_A = + 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'; +const MOCK_PUBLIC_KEY_B = + 'GBCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC'; function buildConfigValues(): Record { return { @@ -34,7 +36,9 @@ function buildHorizonServerMock(streamFn: jest.Mock) { accounts: jest.fn().mockReturnValue({ accountId: jest.fn().mockReturnValue({ stream: streamFn, - call: jest.fn().mockResolvedValue({ account_id: MOCK_PUBLIC_KEY_A, balances: [] }), + call: jest + .fn() + .mockResolvedValue({ account_id: MOCK_PUBLIC_KEY_A, balances: [] }), }), }), }; @@ -164,10 +168,24 @@ describe('BalanceSyncService', () => { providers: [ BalanceSyncService, { provide: ConfigService, useValue: mockConfigService }, - { provide: CACHE_MANAGER, useValue: { get: jest.fn().mockResolvedValue(null), set: jest.fn() } }, + { + provide: CACHE_MANAGER, + useValue: { + get: jest.fn().mockResolvedValue(null), + set: jest.fn(), + }, + }, { provide: EventEmitter2, useValue: { emit: jest.fn() } }, - { provide: StellarService, useValue: { getHorizonServer: jest.fn().mockReturnValue(horizonServerMock) } }, - { provide: getRepositoryToken(ProtocolMetrics), useValue: { findOne: jest.fn(), save: jest.fn() } }, + { + provide: StellarService, + useValue: { + getHorizonServer: jest.fn().mockReturnValue(horizonServerMock), + }, + }, + { + provide: getRepositoryToken(ProtocolMetrics), + useValue: { findOne: jest.fn(), save: jest.fn() }, + }, ], }).compile(); diff --git a/backend/src/modules/blockchain/balance-sync.service.ts b/backend/src/modules/blockchain/balance-sync.service.ts index 4b42e67b6..f901ec051 100644 --- a/backend/src/modules/blockchain/balance-sync.service.ts +++ b/backend/src/modules/blockchain/balance-sync.service.ts @@ -1,4 +1,10 @@ -import { Injectable, Logger, OnModuleDestroy, OnModuleInit, Inject } from '@nestjs/common'; +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, + Inject, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -140,7 +146,9 @@ export class BalanceSyncService implements OnModuleInit, OnModuleDestroy { unsubscribe(publicKey: string): void { if (!this.handles.has(publicKey)) { - this.logger.debug(`unsubscribe called for unknown account ${publicKey}, skipping`); + this.logger.debug( + `unsubscribe called for unknown account ${publicKey}, skipping`, + ); return; } @@ -162,7 +170,9 @@ export class BalanceSyncService implements OnModuleInit, OnModuleDestroy { const accounts = Array.from(this.handles.entries()).map(([, handle]) => { const streamUptimeSeconds = handle.connected && handle.metrics.connectedAt - ? Math.floor((Date.now() - handle.metrics.connectedAt.getTime()) / 1000) + ? Math.floor( + (Date.now() - handle.metrics.connectedAt.getTime()) / 1000, + ) : handle.metrics.streamUptimeSeconds; return { @@ -172,7 +182,10 @@ export class BalanceSyncService implements OnModuleInit, OnModuleDestroy { }); const anyFallbackActive = accounts.some((a) => a.fallbackActive); - const totalReconnects = accounts.reduce((sum, a) => sum + a.reconnectCount, 0); + const totalReconnects = accounts.reduce( + (sum, a) => sum + a.reconnectCount, + 0, + ); return { accounts, anyFallbackActive, totalReconnects }; } @@ -182,7 +195,9 @@ export class BalanceSyncService implements OnModuleInit, OnModuleDestroy { * For each asset balance, compare against the cached value and emit * a BalanceChangedEvent if the balance has changed (Requirements 3.1, 3.3, 3.4). */ - private async processAccountUpdate(accountRecord: Horizon.AccountResponse): Promise { + private async processAccountUpdate( + accountRecord: Horizon.AccountResponse, + ): Promise { const accountId = accountRecord.account_id; for (const balance of accountRecord.balances) { @@ -192,7 +207,10 @@ export class BalanceSyncService implements OnModuleInit, OnModuleDestroy { : (balance as Horizon.HorizonApi.BalanceLineAsset).asset_code; const newBalance = balance.balance; - const previousBalance = await this.readBalanceFromCache(accountId, assetCode); + const previousBalance = await this.readBalanceFromCache( + accountId, + assetCode, + ); if (newBalance !== previousBalance) { await this.writeBalanceToCache(accountId, assetCode, newBalance); @@ -221,7 +239,10 @@ export class BalanceSyncService implements OnModuleInit, OnModuleDestroy { ): Promise { const key = `balance:${publicKey}:${assetCode}`; try { - const value = JSON.stringify({ balance, updatedAt: new Date().toISOString() }); + const value = JSON.stringify({ + balance, + updatedAt: new Date().toISOString(), + }); const ttl = this.cacheTtlSeconds * 1000; // cache-manager uses milliseconds await this.cacheManager.set(key, value, ttl); } catch (err) { @@ -273,7 +294,9 @@ export class BalanceSyncService implements OnModuleInit, OnModuleDestroy { onmessage: (accountRecord) => { handle.connected = true; handle.metrics.connectedAt = handle.metrics.connectedAt ?? new Date(); - this.processAccountUpdate(accountRecord as unknown as Horizon.AccountResponse).catch((err) => + this.processAccountUpdate( + accountRecord as unknown as Horizon.AccountResponse, + ).catch((err) => this.logger.error( `Error processing account update for ${publicKey}: ${(err as Error).message}`, ), @@ -351,8 +374,13 @@ export class BalanceSyncService implements OnModuleInit, OnModuleDestroy { handle.pollTimer = setInterval(async () => { try { const horizonServer = this.stellarService.getHorizonServer(); - const account = await horizonServer.accounts().accountId(publicKey).call(); - await this.processAccountUpdate(account as unknown as Horizon.AccountResponse); + const account = await horizonServer + .accounts() + .accountId(publicKey) + .call(); + await this.processAccountUpdate( + account as unknown as Horizon.AccountResponse, + ); } catch (err) { this.logger.warn( `Polling fallback error for ${publicKey}: ${(err as Error).message}`, @@ -386,7 +414,10 @@ export class BalanceSyncService implements OnModuleInit, OnModuleDestroy { private async persistMetrics(): Promise { try { const summary = this.getMetricsSummary(); - let record = await this.protocolMetricsRepo.findOne({ where: {}, order: { createdAt: 'DESC' } }); + const record = await this.protocolMetricsRepo.findOne({ + where: {}, + order: { createdAt: 'DESC' }, + }); if (record) { record.connectionMetrics = summary as any; await this.protocolMetricsRepo.save(record); @@ -401,7 +432,9 @@ export class BalanceSyncService implements OnModuleInit, OnModuleDestroy { await this.protocolMetricsRepo.save(newRecord); } } catch (err) { - this.logger.warn(`Failed to persist connection metrics: ${(err as Error).message}`); + this.logger.warn( + `Failed to persist connection metrics: ${(err as Error).message}`, + ); } } @@ -412,7 +445,7 @@ export class BalanceSyncService implements OnModuleInit, OnModuleDestroy { const value = this.configService.get(key); if (value === undefined || value === null) { this.logger.warn( - `Config key "${key}" is absent. Using default: ${defaultValue}`, + `Config key "${key}" is absent. Using default: ${String(defaultValue)}`, ); return defaultValue; } diff --git a/backend/src/modules/blockchain/blockchain.controller.spec.ts b/backend/src/modules/blockchain/blockchain.controller.spec.ts index 7ff106284..f67c88365 100644 --- a/backend/src/modules/blockchain/blockchain.controller.spec.ts +++ b/backend/src/modules/blockchain/blockchain.controller.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BlockchainController } from './blockchain.controller'; import { StellarService } from './stellar.service'; +import { BalanceSyncService } from './balance-sync.service'; import { TransactionDto } from './dto/transaction.dto'; const MOCK_PUBLIC_KEY = @@ -34,9 +35,16 @@ describe('BlockchainController', () => { getRecentTransactions: jest.fn().mockResolvedValue(MOCK_TRANSACTIONS), }; + const mockBalanceSyncService = { + getMetricsSummary: jest.fn().mockReturnValue({}), + }; + const module: TestingModule = await Test.createTestingModule({ controllers: [BlockchainController], - providers: [{ provide: StellarService, useValue: mockStellarService }], + providers: [ + { provide: StellarService, useValue: mockStellarService }, + { provide: BalanceSyncService, useValue: mockBalanceSyncService }, + ], }).compile(); controller = module.get(BlockchainController); diff --git a/backend/src/modules/blockchain/blockchain.controller.ts b/backend/src/modules/blockchain/blockchain.controller.ts index c2aac7df7..2ea7d8ef0 100644 --- a/backend/src/modules/blockchain/blockchain.controller.ts +++ b/backend/src/modules/blockchain/blockchain.controller.ts @@ -54,8 +54,13 @@ export class BlockchainController { } @Get('balance-sync/metrics') - @ApiOperation({ summary: 'Get WebSocket connection health metrics for balance sync' }) - @ApiResponse({ status: 200, description: 'Connection metrics summary for all subscribed accounts' }) + @ApiOperation({ + summary: 'Get WebSocket connection health metrics for balance sync', + }) + @ApiResponse({ + status: 200, + description: 'Connection metrics summary for all subscribed accounts', + }) getBalanceSyncMetrics() { return this.balanceSyncService.getMetricsSummary(); } diff --git a/backend/src/modules/blockchain/savings.service.spec.ts b/backend/src/modules/blockchain/savings.service.spec.ts index dfb7bff03..4cc642508 100644 --- a/backend/src/modules/blockchain/savings.service.spec.ts +++ b/backend/src/modules/blockchain/savings.service.spec.ts @@ -5,7 +5,8 @@ import { StellarService } from './stellar.service'; // Requirements: 7.1, 7.2, 7.3 -const MOCK_PUBLIC_KEY = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'; +const MOCK_PUBLIC_KEY = + 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'; function buildAccountMock(nativeBalance: string) { return { diff --git a/backend/src/modules/blockchain/savings.service.ts b/backend/src/modules/blockchain/savings.service.ts index 5484c5193..ed0949561 100644 --- a/backend/src/modules/blockchain/savings.service.ts +++ b/backend/src/modules/blockchain/savings.service.ts @@ -230,7 +230,10 @@ export class SavingsService { const balanceStr = (balance / 10_000_000).toFixed(7); await this.cacheManager.set( cacheKey, - JSON.stringify({ balance: balanceStr, updatedAt: new Date().toISOString() }), + JSON.stringify({ + balance: balanceStr, + updatedAt: new Date().toISOString(), + }), 300_000, ); } catch (cacheErr) { diff --git a/backend/src/modules/cache/cache-strategy.service.ts b/backend/src/modules/cache/cache-strategy.service.ts index 858364aae..b579bfdb3 100644 --- a/backend/src/modules/cache/cache-strategy.service.ts +++ b/backend/src/modules/cache/cache-strategy.service.ts @@ -68,20 +68,31 @@ export class CacheStrategyService { async invalidateByTag(tag: string): Promise { try { - const keys = await this.cacheManager.store.keys(); + const cacheAny = this.cacheManager as unknown as { + store?: { keys?: () => Promise }; + stores?: Array<{ iterator?: () => AsyncIterableIterator }>; + }; + + const keys = cacheAny.store?.keys ? await cacheAny.store.keys() : []; const keysToDelete = keys.filter((k) => k.includes(tag)); - + for (const key of keysToDelete) { await this.del(key); } - - this.logger.debug(`Invalidated ${keysToDelete.length} keys with tag: ${tag}`); + + this.logger.debug( + `Invalidated ${keysToDelete.length} keys with tag: ${tag}`, + ); } catch (error) { this.logger.error(`Cache invalidation error for tag ${tag}:`, error); } } - async warmCache(key: string, loader: () => Promise, ttl?: number): Promise { + async warmCache( + key: string, + loader: () => Promise, + ttl?: number, + ): Promise { try { const data = await loader(); await this.set(key, data, ttl); @@ -122,7 +133,8 @@ export class CacheStrategyService { const total = this.metrics.hits + this.metrics.misses; return { ...this.metrics, - hitRate: total > 0 ? (this.metrics.hits / total * 100).toFixed(2) + '%' : '0%', + hitRate: + total > 0 ? ((this.metrics.hits / total) * 100).toFixed(2) + '%' : '0%', }; } diff --git a/backend/src/modules/cache/cache.module.ts b/backend/src/modules/cache/cache.module.ts index 57ccce5bc..5f0cd7e40 100644 --- a/backend/src/modules/cache/cache.module.ts +++ b/backend/src/modules/cache/cache.module.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common'; import { CacheModule as NestCacheModule } from '@nestjs/cache-manager'; -import * as redisStore from 'cache-manager-redis-store'; import { ConfigService } from '@nestjs/config'; import { CacheStrategyService } from './cache-strategy.service'; import { CacheController } from './cache.controller'; @@ -11,14 +10,10 @@ import { CacheController } from './cache.controller'; inject: [ConfigService], useFactory: async (configService: ConfigService) => { const redisUrl = configService.get('REDIS_URL'); - - if (redisUrl) { - return { - store: redisStore, - url: redisUrl, - ttl: 5 * 60 * 1000, // 5 minutes default - }; - } + + // Keep configuration compatible when REDIS_URL is present, while + // defaulting to in-memory cache if redis store adapter is unavailable. + void redisUrl; // Fallback to in-memory cache return { diff --git a/backend/src/modules/disputes/disputes.module.ts b/backend/src/modules/disputes/disputes.module.ts index 9113cbb4a..600a8ad3a 100644 --- a/backend/src/modules/disputes/disputes.module.ts +++ b/backend/src/modules/disputes/disputes.module.ts @@ -2,11 +2,22 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { DisputesController } from './disputes.controller'; import { DisputesService } from './disputes.service'; -import { Dispute, DisputeMessage, DisputeTimeline } from './entities/dispute.entity'; +import { + Dispute, + DisputeMessage, + DisputeTimeline, +} from './entities/dispute.entity'; import { MedicalClaim } from '../claims/entities/medical-claim.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Dispute, DisputeMessage, DisputeTimeline, MedicalClaim])], + imports: [ + TypeOrmModule.forFeature([ + Dispute, + DisputeMessage, + DisputeTimeline, + MedicalClaim, + ]), + ], controllers: [DisputesController], providers: [DisputesService], exports: [DisputesService, Dispute, DisputeTimeline], diff --git a/backend/src/modules/disputes/entities/dispute.entity.ts b/backend/src/modules/disputes/entities/dispute.entity.ts index 8f6f45437..9467089eb 100644 --- a/backend/src/modules/disputes/entities/dispute.entity.ts +++ b/backend/src/modules/disputes/entities/dispute.entity.ts @@ -47,7 +47,11 @@ export class Dispute { @Column({ type: 'enum', enum: DisputeStatus, default: DisputeStatus.OPEN }) status: DisputeStatus; - @Column({ type: 'enum', enum: DisputePriority, default: DisputePriority.MEDIUM }) + @Column({ + type: 'enum', + enum: DisputePriority, + default: DisputePriority.MEDIUM, + }) priority: DisputePriority; @Column({ nullable: true }) diff --git a/backend/src/modules/health/health-history.service.ts b/backend/src/modules/health/health-history.service.ts index e6f0e15a9..eaeca129e 100644 --- a/backend/src/modules/health/health-history.service.ts +++ b/backend/src/modules/health/health-history.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; -interface HealthCheckResult { +export interface HealthCheckResult { service: string; status: 'up' | 'down' | 'degraded'; responseTime: number; @@ -16,7 +16,7 @@ export class HealthHistoryService { recordCheck(result: HealthCheckResult): void { this.history.push(result); - + if (this.history.length > this.maxHistorySize) { this.history.shift(); } @@ -24,7 +24,7 @@ export class HealthHistoryService { getHistory(service?: string, limit: number = 100): HealthCheckResult[] { let filtered = this.history; - + if (service) { filtered = filtered.filter((h) => h.service === service); } @@ -34,7 +34,7 @@ export class HealthHistoryService { getServiceStats(service: string) { const serviceHistory = this.history.filter((h) => h.service === service); - + if (serviceHistory.length === 0) { return null; } diff --git a/backend/src/modules/health/health.controller.ts b/backend/src/modules/health/health.controller.ts index 916db1f17..83c87e039 100644 --- a/backend/src/modules/health/health.controller.ts +++ b/backend/src/modules/health/health.controller.ts @@ -71,11 +71,11 @@ export class HealthController { 'soroban-rpc', 'horizon', ]; - + if (check.status === 'fulfilled') { return check.value; } - + return { [services[index]]: { status: 'down', @@ -130,7 +130,10 @@ export class HealthController { summary: 'Get health check history', description: 'Retrieve historical health check data', }) - getHistory(@Query('service') service?: string, @Query('limit') limit: number = 100) { + getHistory( + @Query('service') service?: string, + @Query('limit') limit: number = 100, + ) { return { history: this.healthHistory.getHistory(service, limit), }; diff --git a/backend/src/modules/health/indicators/external-services.health.ts b/backend/src/modules/health/indicators/external-services.health.ts index 1108fdf09..6e68b6c5e 100644 --- a/backend/src/modules/health/indicators/external-services.health.ts +++ b/backend/src/modules/health/indicators/external-services.health.ts @@ -20,7 +20,7 @@ export class RedisHealthIndicator extends HealthIndicator { async isHealthy(key: string): Promise { const redisUrl = this.configService.get('REDIS_URL'); - + if (!redisUrl) { return this.getStatus(key, false, { message: 'Redis not configured', @@ -32,14 +32,14 @@ export class RedisHealthIndicator extends HealthIndicator { // Simple ping test const response = await axios.get(redisUrl, { timeout: 5000 }); const responseTime = Date.now() - startTime; - + return this.getStatus(key, true, { responseTime: `${responseTime}ms`, }); } catch (error) { const responseTime = Date.now() - startTime; this.logger.error(`Redis health check failed: ${error}`); - + return this.getStatus(key, false, { responseTime: `${responseTime}ms`, error: error instanceof Error ? error.message : 'Unknown error', @@ -58,7 +58,7 @@ export class EmailServiceHealthIndicator extends HealthIndicator { async isHealthy(key: string): Promise { const mailHost = this.configService.get('MAIL_HOST'); - + if (!mailHost) { return this.getStatus(key, false, { message: 'Email service not configured', @@ -68,16 +68,18 @@ export class EmailServiceHealthIndicator extends HealthIndicator { const startTime = Date.now(); try { // Test SMTP connection - const response = await axios.get(`http://${mailHost}:25`, { timeout: 5000 }); + const response = await axios.get(`http://${mailHost}:25`, { + timeout: 5000, + }); const responseTime = Date.now() - startTime; - + return this.getStatus(key, true, { responseTime: `${responseTime}ms`, }); } catch (error) { const responseTime = Date.now() - startTime; this.logger.warn(`Email service health check failed: ${error}`); - + return this.getStatus(key, false, { responseTime: `${responseTime}ms`, error: error instanceof Error ? error.message : 'Unknown error', @@ -96,7 +98,7 @@ export class SorobanRpcHealthIndicator extends HealthIndicator { async isHealthy(key: string): Promise { const rpcUrl = this.configService.get('SOROBAN_RPC_URL'); - + if (!rpcUrl) { return this.getStatus(key, false, { message: 'Soroban RPC not configured', @@ -110,10 +112,10 @@ export class SorobanRpcHealthIndicator extends HealthIndicator { { jsonrpc: '2.0', method: 'getHealth', params: [], id: 1 }, { timeout: 10000 }, ); - + const responseTime = Date.now() - startTime; const isHealthy = response.data?.result?.status === 'healthy'; - + return this.getStatus(key, isHealthy, { responseTime: `${responseTime}ms`, status: response.data?.result?.status, @@ -121,7 +123,7 @@ export class SorobanRpcHealthIndicator extends HealthIndicator { } catch (error) { const responseTime = Date.now() - startTime; this.logger.error(`Soroban RPC health check failed: ${error}`); - + return this.getStatus(key, false, { responseTime: `${responseTime}ms`, error: error instanceof Error ? error.message : 'Unknown error', @@ -140,7 +142,7 @@ export class HorizonHealthIndicator extends HealthIndicator { async isHealthy(key: string): Promise { const horizonUrl = this.configService.get('HORIZON_URL'); - + if (!horizonUrl) { return this.getStatus(key, false, { message: 'Horizon not configured', @@ -149,16 +151,18 @@ export class HorizonHealthIndicator extends HealthIndicator { const startTime = Date.now(); try { - const response = await axios.get(`${horizonUrl}/health`, { timeout: 10000 }); + const response = await axios.get(`${horizonUrl}/health`, { + timeout: 10000, + }); const responseTime = Date.now() - startTime; - + return this.getStatus(key, true, { responseTime: `${responseTime}ms`, }); } catch (error) { const responseTime = Date.now() - startTime; this.logger.error(`Horizon health check failed: ${error}`); - + return this.getStatus(key, false, { responseTime: `${responseTime}ms`, error: error instanceof Error ? error.message : 'Unknown error', diff --git a/backend/src/modules/notifications/entities/notification.entity.ts b/backend/src/modules/notifications/entities/notification.entity.ts index 48baccced..01f300b07 100644 --- a/backend/src/modules/notifications/entities/notification.entity.ts +++ b/backend/src/modules/notifications/entities/notification.entity.ts @@ -22,6 +22,9 @@ export enum NotificationType { PRODUCT_ALERT_TRIGGERED = 'PRODUCT_ALERT_TRIGGERED', REBALANCING_RECOMMENDED = 'REBALANCING_RECOMMENDED', ADMIN_CAPACITY_ALERT = 'ADMIN_CAPACITY_ALERT', + ADMIN_BROADCAST = 'ADMIN_BROADCAST', + REFERRAL_COMPLETED = 'REFERRAL_COMPLETED', + REFERRAL_REWARD = 'REFERRAL_REWARD', } @Entity('notifications') diff --git a/backend/src/modules/performance/query-logger.service.ts b/backend/src/modules/performance/query-logger.service.ts index c9e23e669..4e714a67e 100644 --- a/backend/src/modules/performance/query-logger.service.ts +++ b/backend/src/modules/performance/query-logger.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { DataSource, QueryRunner } from 'typeorm'; -interface QueryMetrics { +export interface QueryMetrics { query: string; duration: number; timestamp: Date; @@ -21,40 +21,14 @@ export class QueryLoggerService { } private setupQueryLogging() { - const queryRunner = this.dataSource.createQueryRunner(); - - this.dataSource.subscribers?.forEach((subscriber) => { - if (subscriber.beforeQuery) { - const originalBeforeQuery = subscriber.beforeQuery.bind(subscriber); - subscriber.beforeQuery = (event) => { - event.startTime = Date.now(); - return originalBeforeQuery(event); - }; - } - - if (subscriber.afterQuery) { - const originalAfterQuery = subscriber.afterQuery.bind(subscriber); - subscriber.afterQuery = (event) => { - const duration = Date.now() - (event.startTime || Date.now()); - - if (duration > this.slowQueryThreshold) { - this.recordSlowQuery({ - query: event.query, - duration, - timestamp: new Date(), - params: event.parameters, - }); - } - - return originalAfterQuery(event); - }; - } - }); + // Placeholder: maintain explicit method for future query telemetry wiring. + // Existing TypeORM event payloads do not expose mutable timing metadata. + void this.dataSource; } private recordSlowQuery(metrics: QueryMetrics) { this.slowQueries.push(metrics); - + if (this.slowQueries.length > this.maxStoredQueries) { this.slowQueries.shift(); } @@ -110,7 +84,9 @@ export class QueryLoggerService { queryMap.forEach((count, query) => { if (count > 5) { - patterns.push(`Query executed ${count} times: ${query.substring(0, 100)}`); + patterns.push( + `Query executed ${count} times: ${query.substring(0, 100)}`, + ); } }); diff --git a/backend/src/modules/referrals/admin-referrals.controller.ts b/backend/src/modules/referrals/admin-referrals.controller.ts index 893156e52..d9d39c6b6 100644 --- a/backend/src/modules/referrals/admin-referrals.controller.ts +++ b/backend/src/modules/referrals/admin-referrals.controller.ts @@ -9,17 +9,19 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../common/guards/roles.guard'; import { Roles } from '../../common/decorators/roles.decorator'; import { Role } from '../../common/enums/role.enum'; import { ReferralsService } from './referrals.service'; import { CampaignsService } from './campaigns.service'; -import { - CreateCampaignDto, - UpdateCampaignDto, -} from './dto/campaign.dto'; +import { CreateCampaignDto, UpdateCampaignDto } from './dto/campaign.dto'; import { UpdateReferralStatusDto } from './dto/referral.dto'; import { ReferralStatus } from './entities/referral.entity'; @@ -100,7 +102,9 @@ export class AdminReferralsController { } @Post(':id/distribute-rewards') - @ApiOperation({ summary: 'Manually trigger reward distribution for a referral' }) + @ApiOperation({ + summary: 'Manually trigger reward distribution for a referral', + }) async distributeRewards(@Param('id') id: string) { await this.referralsService.distributeRewards(id); return { message: 'Rewards distributed successfully' }; @@ -111,13 +115,21 @@ export class AdminReferralsController { async getReferralAnalytics() { // This could be expanded with more detailed analytics const allReferrals = await this.referralsService.getAllReferrals(); - + const analytics = { totalReferrals: allReferrals.length, - pendingReferrals: allReferrals.filter((r) => r.status === ReferralStatus.PENDING).length, - completedReferrals: allReferrals.filter((r) => r.status === ReferralStatus.COMPLETED).length, - rewardedReferrals: allReferrals.filter((r) => r.status === ReferralStatus.REWARDED).length, - fraudulentReferrals: allReferrals.filter((r) => r.status === ReferralStatus.FRAUDULENT).length, + pendingReferrals: allReferrals.filter( + (r) => r.status === ReferralStatus.PENDING, + ).length, + completedReferrals: allReferrals.filter( + (r) => r.status === ReferralStatus.COMPLETED, + ).length, + rewardedReferrals: allReferrals.filter( + (r) => r.status === ReferralStatus.REWARDED, + ).length, + fraudulentReferrals: allReferrals.filter( + (r) => r.status === ReferralStatus.FRAUDULENT, + ).length, totalRewardsDistributed: allReferrals .filter((r) => r.status === ReferralStatus.REWARDED && r.rewardAmount) .reduce((sum, r) => sum + parseFloat(r.rewardAmount!), 0) diff --git a/backend/src/modules/referrals/campaigns.service.ts b/backend/src/modules/referrals/campaigns.service.ts index 1646fa956..14643c327 100644 --- a/backend/src/modules/referrals/campaigns.service.ts +++ b/backend/src/modules/referrals/campaigns.service.ts @@ -38,14 +38,12 @@ export class CampaignsService { return this.campaignRepository .createQueryBuilder('campaign') .where('campaign.isActive = :isActive', { isActive: true }) - .andWhere( - '(campaign.startDate IS NULL OR campaign.startDate <= :now)', - { now }, - ) - .andWhere( - '(campaign.endDate IS NULL OR campaign.endDate >= :now)', - { now }, - ) + .andWhere('(campaign.startDate IS NULL OR campaign.startDate <= :now)', { + now, + }) + .andWhere('(campaign.endDate IS NULL OR campaign.endDate >= :now)', { + now, + }) .getMany(); } @@ -68,7 +66,8 @@ export class CampaignsService { if (dto.rewardAmount !== undefined) campaign.rewardAmount = dto.rewardAmount.toString(); if (dto.refereeRewardAmount !== undefined) - campaign.refereeRewardAmount = dto.refereeRewardAmount?.toString() || null; + campaign.refereeRewardAmount = + dto.refereeRewardAmount?.toString() || null; if (dto.minDepositAmount !== undefined) campaign.minDepositAmount = dto.minDepositAmount.toString(); if (dto.maxRewardsPerUser !== undefined) diff --git a/backend/src/modules/referrals/dto/campaign.dto.ts b/backend/src/modules/referrals/dto/campaign.dto.ts index 2e9ff8ea4..aa275019f 100644 --- a/backend/src/modules/referrals/dto/campaign.dto.ts +++ b/backend/src/modules/referrals/dto/campaign.dto.ts @@ -1,4 +1,11 @@ -import { IsString, IsOptional, IsNumber, IsBoolean, IsDateString, Min } from 'class-validator'; +import { + IsString, + IsOptional, + IsNumber, + IsBoolean, + IsDateString, + Min, +} from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateCampaignDto { @@ -22,7 +29,10 @@ export class CreateCampaignDto { @Min(0) refereeRewardAmount?: number; - @ApiPropertyOptional({ description: 'Minimum deposit amount to qualify', default: 0 }) + @ApiPropertyOptional({ + description: 'Minimum deposit amount to qualify', + default: 0, + }) @IsOptional() @IsNumber() @Min(0) diff --git a/backend/src/modules/referrals/dto/referral.dto.ts b/backend/src/modules/referrals/dto/referral.dto.ts index ee33e527d..9ad0bddff 100644 --- a/backend/src/modules/referrals/dto/referral.dto.ts +++ b/backend/src/modules/referrals/dto/referral.dto.ts @@ -1,9 +1,18 @@ -import { IsString, IsOptional, IsUUID, IsEnum, IsNumber, Min } from 'class-validator'; +import { + IsString, + IsOptional, + IsUUID, + IsEnum, + IsNumber, + Min, +} from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ReferralStatus } from '../entities/referral.entity'; export class CreateReferralDto { - @ApiPropertyOptional({ description: 'Campaign ID to associate with this referral' }) + @ApiPropertyOptional({ + description: 'Campaign ID to associate with this referral', + }) @IsOptional() @IsUUID() campaignId?: string; @@ -32,7 +41,7 @@ export class ReferralStatsDto { totalRewardsEarned: string; @ApiProperty() - referralCode: string; + referralCode: string | null; } export class ReferralResponseDto { diff --git a/backend/src/modules/referrals/referral-events.listener.ts b/backend/src/modules/referrals/referral-events.listener.ts index ec998b6eb..9de02a734 100644 --- a/backend/src/modules/referrals/referral-events.listener.ts +++ b/backend/src/modules/referrals/referral-events.listener.ts @@ -2,7 +2,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Notification, NotificationType } from '../notifications/entities/notification.entity'; +import { + Notification, + NotificationType, +} from '../notifications/entities/notification.entity'; import { ReferralsService } from './referrals.service'; @Injectable() @@ -25,8 +28,13 @@ export class ReferralEventsListener { } @OnEvent('user.signup-with-referral') - async handleSignupWithReferral(payload: { userId: string; referralCode: string }) { - this.logger.log(`Applying referral code ${payload.referralCode} for user ${payload.userId}`); + async handleSignupWithReferral(payload: { + userId: string; + referralCode: string; + }) { + this.logger.log( + `Applying referral code ${payload.referralCode} for user ${payload.userId}`, + ); try { await this.referralsService.applyReferralCode( payload.referralCode, @@ -51,7 +59,8 @@ export class ReferralEventsListener { userId: payload.referrerId, type: NotificationType.REFERRAL_COMPLETED, title: 'Referral Completed!', - message: 'Your referral has completed their first deposit. Rewards will be distributed soon.', + message: + 'Your referral has completed their first deposit. Rewards will be distributed soon.', metadata: { referralId: payload.referralId }, }); diff --git a/backend/src/modules/referrals/referrals.controller.ts b/backend/src/modules/referrals/referrals.controller.ts index 2bdc5f7c6..af949e40e 100644 --- a/backend/src/modules/referrals/referrals.controller.ts +++ b/backend/src/modules/referrals/referrals.controller.ts @@ -8,7 +8,12 @@ import { HttpCode, HttpStatus, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { ReferralsService } from './referrals.service'; import { @@ -26,11 +31,11 @@ export class ReferralsController { @Post('generate') @ApiOperation({ summary: 'Generate a referral code for the current user' }) - @ApiResponse({ status: 201, description: 'Referral code generated successfully' }) - async generateReferralCode( - @Request() req, - @Body() dto: CreateReferralDto, - ) { + @ApiResponse({ + status: 201, + description: 'Referral code generated successfully', + }) + async generateReferralCode(@Request() req, @Body() dto: CreateReferralDto) { const referral = await this.referralsService.generateReferralCode( req.user.userId, dto.campaignId, @@ -53,8 +58,10 @@ export class ReferralsController { @ApiOperation({ summary: 'Get list of users referred by current user' }) @ApiResponse({ status: 200, type: [ReferralResponseDto] }) async getMyReferrals(@Request() req) { - const referrals = await this.referralsService.getUserReferrals(req.user.userId); - + const referrals = await this.referralsService.getUserReferrals( + req.user.userId, + ); + return referrals.map((r) => ({ id: r.id, referralCode: r.referralCode, @@ -69,7 +76,9 @@ export class ReferralsController { @Post('check-completion') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Internal: Check if referral should be completed after deposit' }) + @ApiOperation({ + summary: 'Internal: Check if referral should be completed after deposit', + }) async checkReferralCompletion( @Body() body: { userId: string; depositAmount: string }, ) { diff --git a/backend/src/modules/referrals/referrals.integration.spec.ts b/backend/src/modules/referrals/referrals.integration.spec.ts index d5f856c97..e39fb6773 100644 --- a/backend/src/modules/referrals/referrals.integration.spec.ts +++ b/backend/src/modules/referrals/referrals.integration.spec.ts @@ -231,9 +231,7 @@ describe('Referrals Integration Tests', () => { }); it('should require authentication for referral endpoints', async () => { - await request(app.getHttpServer()) - .get('/referrals/stats') - .expect(401); + await request(app.getHttpServer()).get('/referrals/stats').expect(401); await request(app.getHttpServer()) .post('/referrals/generate') diff --git a/backend/src/modules/referrals/referrals.service.spec.ts b/backend/src/modules/referrals/referrals.service.spec.ts index 09fb97bcc..59d44aa64 100644 --- a/backend/src/modules/referrals/referrals.service.spec.ts +++ b/backend/src/modules/referrals/referrals.service.spec.ts @@ -7,7 +7,11 @@ import { Referral, ReferralStatus } from './entities/referral.entity'; import { ReferralCampaign } from './entities/referral-campaign.entity'; import { User } from '../user/entities/user.entity'; import { Transaction } from '../transactions/entities/transaction.entity'; -import { NotFoundException, BadRequestException, ConflictException } from '@nestjs/common'; +import { + NotFoundException, + BadRequestException, + ConflictException, +} from '@nestjs/common'; describe('ReferralsService', () => { let service: ReferralsService; @@ -77,10 +81,16 @@ describe('ReferralsService', () => { }).compile(); service = module.get(ReferralsService); - referralRepository = module.get>(getRepositoryToken(Referral)); - campaignRepository = module.get>(getRepositoryToken(ReferralCampaign)); + referralRepository = module.get>( + getRepositoryToken(Referral), + ); + campaignRepository = module.get>( + getRepositoryToken(ReferralCampaign), + ); userRepository = module.get>(getRepositoryToken(User)); - transactionRepository = module.get>(getRepositoryToken(Transaction)); + transactionRepository = module.get>( + getRepositoryToken(Transaction), + ); eventEmitter = module.get(EventEmitter2); }); @@ -92,19 +102,27 @@ describe('ReferralsService', () => { it('should generate a new referral code for a user', async () => { jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any); jest.spyOn(referralRepository, 'findOne').mockResolvedValue(null); - jest.spyOn(referralRepository, 'create').mockReturnValue(mockReferral as any); - jest.spyOn(referralRepository, 'save').mockResolvedValue(mockReferral as any); + jest + .spyOn(referralRepository, 'create') + .mockReturnValue(mockReferral as any); + jest + .spyOn(referralRepository, 'save') + .mockResolvedValue(mockReferral as any); const result = await service.generateReferralCode('user-1'); expect(result).toBeDefined(); expect(result.referralCode).toBeDefined(); - expect(userRepository.findOne).toHaveBeenCalledWith({ where: { id: 'user-1' } }); + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + }); }); it('should return existing referral code if already exists', async () => { jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any); - jest.spyOn(referralRepository, 'findOne').mockResolvedValue(mockReferral as any); + jest + .spyOn(referralRepository, 'findOne') + .mockResolvedValue(mockReferral as any); const result = await service.generateReferralCode('user-1'); @@ -115,16 +133,17 @@ describe('ReferralsService', () => { it('should throw NotFoundException if user not found', async () => { jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); - await expect(service.generateReferralCode('invalid-user')).rejects.toThrow( - NotFoundException, - ); + await expect( + service.generateReferralCode('invalid-user'), + ).rejects.toThrow(NotFoundException); }); }); describe('applyReferralCode', () => { it('should apply referral code successfully', async () => { const referral = { ...mockReferral, refereeId: null }; - jest.spyOn(referralRepository, 'findOne') + jest + .spyOn(referralRepository, 'findOne') .mockResolvedValueOnce(referral as any) .mockResolvedValueOnce(null); jest.spyOn(referralRepository, 'save').mockResolvedValue(referral as any); @@ -137,26 +156,30 @@ describe('ReferralsService', () => { it('should throw NotFoundException for invalid code', async () => { jest.spyOn(referralRepository, 'findOne').mockResolvedValue(null); - await expect(service.applyReferralCode('INVALID', 'user-2')).rejects.toThrow( - NotFoundException, - ); + await expect( + service.applyReferralCode('INVALID', 'user-2'), + ).rejects.toThrow(NotFoundException); }); it('should throw ConflictException if code already used', async () => { const usedReferral = { ...mockReferral, refereeId: 'user-3' }; - jest.spyOn(referralRepository, 'findOne').mockResolvedValue(usedReferral as any); + jest + .spyOn(referralRepository, 'findOne') + .mockResolvedValue(usedReferral as any); - await expect(service.applyReferralCode('ABC12345', 'user-2')).rejects.toThrow( - ConflictException, - ); + await expect( + service.applyReferralCode('ABC12345', 'user-2'), + ).rejects.toThrow(ConflictException); }); it('should throw BadRequestException if user tries to use own code', async () => { - jest.spyOn(referralRepository, 'findOne').mockResolvedValue(mockReferral as any); + jest + .spyOn(referralRepository, 'findOne') + .mockResolvedValue(mockReferral as any); - await expect(service.applyReferralCode('ABC12345', 'user-1')).rejects.toThrow( - BadRequestException, - ); + await expect( + service.applyReferralCode('ABC12345', 'user-1'), + ).rejects.toThrow(BadRequestException); }); }); @@ -165,9 +188,15 @@ describe('ReferralsService', () => { const referrals = [ { ...mockReferral, status: ReferralStatus.PENDING }, { ...mockReferral, status: ReferralStatus.COMPLETED }, - { ...mockReferral, status: ReferralStatus.REWARDED, rewardAmount: '10' }, + { + ...mockReferral, + status: ReferralStatus.REWARDED, + rewardAmount: '10', + }, ]; - jest.spyOn(referralRepository, 'find').mockResolvedValue(referrals as any); + jest + .spyOn(referralRepository, 'find') + .mockResolvedValue(referrals as any); const stats = await service.getReferralStats('user-1'); diff --git a/backend/src/modules/referrals/referrals.service.ts b/backend/src/modules/referrals/referrals.service.ts index 9110717dc..e5baf9f8b 100644 --- a/backend/src/modules/referrals/referrals.service.ts +++ b/backend/src/modules/referrals/referrals.service.ts @@ -10,7 +10,10 @@ import { Repository, MoreThan } from 'typeorm'; import { Referral, ReferralStatus } from './entities/referral.entity'; import { ReferralCampaign } from './entities/referral-campaign.entity'; import { User } from '../user/entities/user.entity'; -import { Transaction, TxType } from '../transactions/entities/transaction.entity'; +import { + Transaction, + TxType, +} from '../transactions/entities/transaction.entity'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { randomBytes } from 'crypto'; @@ -33,15 +36,22 @@ export class ReferralsService { /** * Generate a unique referral code for a user */ - async generateReferralCode(userId: string, campaignId?: string): Promise { + async generateReferralCode( + userId: string, + campaignId?: string, + ): Promise { const user = await this.userRepository.findOne({ where: { id: userId } }); if (!user) { throw new NotFoundException('User not found'); } // Check if user already has an active referral code + const existingWhere = campaignId + ? { referrerId: userId, campaignId } + : { referrerId: userId, campaignId: null as any }; + const existing = await this.referralRepository.findOne({ - where: { referrerId: userId, campaignId: campaignId || null }, + where: existingWhere, }); if (existing) { @@ -51,7 +61,9 @@ export class ReferralsService { // Validate campaign if provided let campaign: ReferralCampaign | null = null; if (campaignId) { - campaign = await this.campaignRepository.findOne({ where: { id: campaignId } }); + campaign = await this.campaignRepository.findOne({ + where: { id: campaignId }, + }); if (!campaign || !campaign.isActive) { throw new BadRequestException('Invalid or inactive campaign'); } @@ -73,7 +85,10 @@ export class ReferralsService { /** * Apply a referral code during user signup */ - async applyReferralCode(referralCode: string, refereeId: string): Promise { + async applyReferralCode( + referralCode: string, + refereeId: string, + ): Promise { const referral = await this.referralRepository.findOne({ where: { referralCode }, relations: ['referrer', 'campaign'], @@ -94,7 +109,10 @@ export class ReferralsService { // Check if campaign is still valid if (referral.campaign) { const now = new Date(); - if (referral.campaign.endDate && new Date(referral.campaign.endDate) < now) { + if ( + referral.campaign.endDate && + new Date(referral.campaign.endDate) < now + ) { referral.status = ReferralStatus.EXPIRED; await this.referralRepository.save(referral); throw new BadRequestException('Referral campaign has expired'); @@ -113,13 +131,18 @@ export class ReferralsService { referral.refereeId = refereeId; await this.referralRepository.save(referral); - this.logger.log(`Referral code ${referralCode} applied for user ${refereeId}`); + this.logger.log( + `Referral code ${referralCode} applied for user ${refereeId}`, + ); } /** * Check and complete referral when user makes first deposit */ - async checkAndCompleteReferral(userId: string, depositAmount: string): Promise { + async checkAndCompleteReferral( + userId: string, + depositAmount: string, + ): Promise { const referral = await this.referralRepository.findOne({ where: { refereeId: userId, status: ReferralStatus.PENDING }, relations: ['referrer', 'campaign'], @@ -132,7 +155,7 @@ export class ReferralsService { // Check minimum deposit requirement const campaign = referral.campaign; const minDeposit = campaign?.minDepositAmount || '0'; - + if (parseFloat(depositAmount) < parseFloat(minDeposit)) { this.logger.log( `Deposit amount ${depositAmount} below minimum ${minDeposit} for referral ${referral.id}`, @@ -240,9 +263,15 @@ export class ReferralsService { const stats = { totalReferrals: referrals.length, - pendingReferrals: referrals.filter((r) => r.status === ReferralStatus.PENDING).length, - completedReferrals: referrals.filter((r) => r.status === ReferralStatus.COMPLETED).length, - rewardedReferrals: referrals.filter((r) => r.status === ReferralStatus.REWARDED).length, + pendingReferrals: referrals.filter( + (r) => r.status === ReferralStatus.PENDING, + ).length, + completedReferrals: referrals.filter( + (r) => r.status === ReferralStatus.COMPLETED, + ).length, + rewardedReferrals: referrals.filter( + (r) => r.status === ReferralStatus.REWARDED, + ).length, totalRewardsEarned: referrals .filter((r) => r.status === ReferralStatus.REWARDED && r.rewardAmount) .reduce((sum, r) => sum + parseFloat(r.rewardAmount!), 0) @@ -278,7 +307,9 @@ export class ReferralsService { }); if (recentReferrals > 10) { - this.logger.warn(`Suspicious activity: ${recentReferrals} referrals in 24h`); + this.logger.warn( + `Suspicious activity: ${recentReferrals} referrals in 24h`, + ); return true; } @@ -290,7 +321,9 @@ export class ReferralsService { // If only one deposit and immediate withdrawal, flag as suspicious const deposits = transactions.filter((t) => t.type === TxType.DEPOSIT); - const withdrawals = transactions.filter((t) => t.type === TxType.WITHDRAW); + const withdrawals = transactions.filter( + (t) => t.type === TxType.WITHDRAW, + ); if (deposits.length === 1 && withdrawals.length > 0) { const timeDiff = @@ -298,7 +331,9 @@ export class ReferralsService { new Date(deposits[0].createdAt).getTime(); if (timeDiff < 60 * 60 * 1000) { // Less than 1 hour - this.logger.warn(`Suspicious withdrawal pattern for user ${referral.refereeId}`); + this.logger.warn( + `Suspicious withdrawal pattern for user ${referral.refereeId}`, + ); return true; } } diff --git a/backend/src/modules/savings/dto/create-auto-deposit.dto.ts b/backend/src/modules/savings/dto/create-auto-deposit.dto.ts index a956c4226..fe081f12d 100644 --- a/backend/src/modules/savings/dto/create-auto-deposit.dto.ts +++ b/backend/src/modules/savings/dto/create-auto-deposit.dto.ts @@ -14,7 +14,10 @@ export class CreateAutoDepositDto { @Min(1) amount: number; - @ApiProperty({ enum: AutoDepositFrequency, example: AutoDepositFrequency.WEEKLY }) + @ApiProperty({ + enum: AutoDepositFrequency, + example: AutoDepositFrequency.WEEKLY, + }) @IsEnum(AutoDepositFrequency) frequency: AutoDepositFrequency; diff --git a/backend/src/modules/savings/dto/create-custom-milestone.dto.ts b/backend/src/modules/savings/dto/create-custom-milestone.dto.ts index 1dbf58d34..287822194 100644 --- a/backend/src/modules/savings/dto/create-custom-milestone.dto.ts +++ b/backend/src/modules/savings/dto/create-custom-milestone.dto.ts @@ -1,6 +1,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsBoolean, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { + IsBoolean, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; export class CreateCustomMilestoneDto { @ApiProperty({ example: 'Halfway celebration' }) diff --git a/backend/src/modules/savings/dto/create-goal-from-template.dto.ts b/backend/src/modules/savings/dto/create-goal-from-template.dto.ts index 233e9f5af..56d85b51d 100644 --- a/backend/src/modules/savings/dto/create-goal-from-template.dto.ts +++ b/backend/src/modules/savings/dto/create-goal-from-template.dto.ts @@ -1,6 +1,12 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { + IsDateString, + IsNumber, + IsOptional, + IsString, + Min, +} from 'class-validator'; export class CreateGoalFromTemplateDto { @ApiPropertyOptional({ example: 'Emergency Buffer' }) diff --git a/backend/src/modules/savings/savings.controller.enhanced.spec.ts b/backend/src/modules/savings/savings.controller.enhanced.spec.ts index e886fac76..4d534ed90 100644 --- a/backend/src/modules/savings/savings.controller.enhanced.spec.ts +++ b/backend/src/modules/savings/savings.controller.enhanced.spec.ts @@ -21,6 +21,13 @@ import { WithdrawalRequest } from './entities/withdrawal-request.entity'; import { Transaction } from '../transactions/entities/transaction.entity'; import { RpcThrottleGuard } from '../../common/guards/rpc-throttle.guard'; import { Reflector } from '@nestjs/core'; +import { RecommendationService } from './services/recommendation.service'; +import { SavingsProductVersionAudit } from './entities/savings-product-version-audit.entity'; +import { WaitlistService } from './waitlist.service'; +import { GoalTemplatesService } from './services/goal-templates.service'; +import { GoalMilestonesService } from './services/goal-milestones.service'; +import { ProductComparisonService } from './services/product-comparison.service'; +import { AutoDepositService } from './services/auto-deposit.service'; describe('SavingsController (Enhanced)', () => { let controller: SavingsController; @@ -57,17 +64,48 @@ describe('SavingsController (Enhanced)', () => { provide: getRepositoryToken(SavingsProduct), useValue: { find: jest.fn().mockResolvedValue(mockProducts), - findOneBy: jest.fn(), + findOneBy: jest.fn(async ({ id }) => + mockProducts.find((p) => p.id === id) || null, + ), }, }, { provide: getRepositoryToken(UserSubscription), useValue: {} }, { provide: getRepositoryToken(SavingsGoal), useValue: {} }, { provide: getRepositoryToken(User), useValue: {} }, + { + provide: getRepositoryToken(SavingsProductVersionAudit), + useValue: { create: jest.fn((v) => v), save: jest.fn() }, + }, { provide: getRepositoryToken(WithdrawalRequest), useValue: {} }, { provide: getRepositoryToken(Transaction), useValue: {} }, { provide: BlockchainSavingsService, useValue: {} }, { provide: PredictiveEvaluatorService, useValue: {} }, - { provide: RecommendationService, useValue: { getRecommendations: jest.fn() } }, + { provide: WaitlistService, useValue: { joinWaitlist: jest.fn() } }, + { + provide: RecommendationService, + useValue: { getRecommendations: jest.fn() }, + }, + { + provide: GoalTemplatesService, + useValue: { listTemplates: jest.fn(), createGoalFromTemplate: jest.fn() }, + }, + { + provide: GoalMilestonesService, + useValue: { getGoalMilestones: jest.fn(), addCustomMilestone: jest.fn() }, + }, + { + provide: ProductComparisonService, + useValue: { compareProducts: jest.fn() }, + }, + { + provide: AutoDepositService, + useValue: { + createSchedule: jest.fn(), + listSchedules: jest.fn(), + pauseSchedule: jest.fn(), + cancelSchedule: jest.fn(), + }, + }, { provide: ConfigService, useValue: { get: jest.fn() } }, { provide: CACHE_MANAGER, useValue: { del: jest.fn() } }, { provide: 'THROTTLER:MODULE_OPTIONS', useValue: {} }, diff --git a/backend/src/modules/savings/savings.controller.ts b/backend/src/modules/savings/savings.controller.ts index c00986c72..95fb14506 100644 --- a/backend/src/modules/savings/savings.controller.ts +++ b/backend/src/modules/savings/savings.controller.ts @@ -384,7 +384,10 @@ export class SavingsController { @Body() dto: CompareProductsDto, @CurrentUser() user: { id: string; email: string }, ) { - return this.productComparisonService.compareProducts(user.id, dto.productIds); + return this.productComparisonService.compareProducts( + user.id, + dto.productIds, + ); } @Post('auto-deposit/create') @@ -402,7 +405,9 @@ export class SavingsController { @Get('auto-deposit') @UseGuards(JwtAuthGuard) @ApiBearerAuth() - @ApiOperation({ summary: 'List all recurring auto-deposit schedules for user' }) + @ApiOperation({ + summary: 'List all recurring auto-deposit schedules for user', + }) async listAutoDepositSchedules( @CurrentUser() user: { id: string; email: string }, ) { diff --git a/backend/src/modules/savings/savings.service.spec.ts b/backend/src/modules/savings/savings.service.spec.ts index 62c590073..dd0476e3d 100644 --- a/backend/src/modules/savings/savings.service.spec.ts +++ b/backend/src/modules/savings/savings.service.spec.ts @@ -11,6 +11,8 @@ import { User } from '../user/entities/user.entity'; import { SavingsService as BlockchainSavingsService } from '../blockchain/savings.service'; import { WithdrawalRequest } from './entities/withdrawal-request.entity'; import { Transaction } from '../transactions/entities/transaction.entity'; +import { SavingsProductVersionAudit } from './entities/savings-product-version-audit.entity'; +import { WaitlistService } from './waitlist.service'; describe('SavingsService', () => { let service: SavingsService; @@ -81,6 +83,10 @@ describe('SavingsService', () => { provide: getRepositoryToken(WithdrawalRequest), useValue: { create: jest.fn(), save: jest.fn(), findOne: jest.fn() }, }, + { + provide: getRepositoryToken(SavingsProductVersionAudit), + useValue: { create: jest.fn((v) => v), save: jest.fn() }, + }, { provide: getRepositoryToken(Transaction), useValue: { create: jest.fn((v) => v), save: jest.fn() }, @@ -113,6 +119,10 @@ describe('SavingsService', () => { provide: CACHE_MANAGER, useValue: cacheManager, }, + { + provide: WaitlistService, + useValue: { joinWaitlist: jest.fn() }, + }, ], }).compile(); diff --git a/backend/src/modules/savings/services/auto-deposit.service.spec.ts b/backend/src/modules/savings/services/auto-deposit.service.spec.ts index cc224f78f..e38d02be3 100644 --- a/backend/src/modules/savings/services/auto-deposit.service.spec.ts +++ b/backend/src/modules/savings/services/auto-deposit.service.spec.ts @@ -54,7 +54,10 @@ describe('AutoDepositService', () => { }, { provide: getRepositoryToken(SavingsProduct), useValue: productRepo }, { provide: getRepositoryToken(User), useValue: userRepo }, - { provide: BlockchainSavingsService, useValue: blockchainSavingsService }, + { + provide: BlockchainSavingsService, + useValue: blockchainSavingsService, + }, { provide: MailService, useValue: mailService }, { provide: EventEmitter2, useValue: eventEmitter }, ], diff --git a/backend/src/modules/savings/services/auto-deposit.service.ts b/backend/src/modules/savings/services/auto-deposit.service.ts index 7474412a0..9f0a88025 100644 --- a/backend/src/modules/savings/services/auto-deposit.service.ts +++ b/backend/src/modules/savings/services/auto-deposit.service.ts @@ -45,7 +45,9 @@ export class AutoDepositService { } if (dto.amount < Number(product.minAmount)) { - throw new BadRequestException('Amount is below minimum for selected product'); + throw new BadRequestException( + 'Amount is below minimum for selected product', + ); } const now = new Date(); @@ -104,7 +106,9 @@ export class AutoDepositService { }); if (!user) { - this.logger.warn(`User ${schedule.userId} not found for auto-deposit ${schedule.id}`); + this.logger.warn( + `User ${schedule.userId} not found for auto-deposit ${schedule.id}`, + ); return; } diff --git a/backend/src/modules/savings/services/goal-templates.service.spec.ts b/backend/src/modules/savings/services/goal-templates.service.spec.ts index 584e10a24..89be6359d 100644 --- a/backend/src/modules/savings/services/goal-templates.service.spec.ts +++ b/backend/src/modules/savings/services/goal-templates.service.spec.ts @@ -28,7 +28,10 @@ describe('GoalTemplatesService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ GoalTemplatesService, - { provide: getRepositoryToken(SavingsGoalTemplate), useValue: templateRepo }, + { + provide: getRepositoryToken(SavingsGoalTemplate), + useValue: templateRepo, + }, { provide: getRepositoryToken(SavingsGoalTemplateUsage), useValue: usageRepo, @@ -63,9 +66,13 @@ describe('GoalTemplatesService', () => { }); savingsService.createGoal.mockResolvedValue({ id: 'goal-1' }); - const result = await service.createGoalFromTemplate('user-1', 'template-1', { - targetAmount: 3000, - }); + const result = await service.createGoalFromTemplate( + 'user-1', + 'template-1', + { + targetAmount: 3000, + }, + ); expect(result.goal.id).toBe('goal-1'); expect(savingsService.createGoal).toHaveBeenCalledWith( diff --git a/backend/src/modules/savings/services/goal-templates.service.ts b/backend/src/modules/savings/services/goal-templates.service.ts index f71f7fabd..5135eaa52 100644 --- a/backend/src/modules/savings/services/goal-templates.service.ts +++ b/backend/src/modules/savings/services/goal-templates.service.ts @@ -42,7 +42,8 @@ export class GoalTemplatesService { throw new NotFoundException('Savings goal template not found'); } - const durationMonths = dto.durationMonths ?? template.suggestedDurationMonths; + const durationMonths = + dto.durationMonths ?? template.suggestedDurationMonths; const targetDate = dto.targetDate ? new Date(dto.targetDate) : this.resolveTargetDate(durationMonths); diff --git a/backend/src/modules/savings/services/interest-calculation.service.ts b/backend/src/modules/savings/services/interest-calculation.service.ts index 7a273fe10..2419fe332 100644 --- a/backend/src/modules/savings/services/interest-calculation.service.ts +++ b/backend/src/modules/savings/services/interest-calculation.service.ts @@ -4,7 +4,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { v4 as uuidv4 } from 'uuid'; -import { UserSubscription, SubscriptionStatus } from '../entities/user-subscription.entity'; +import { + UserSubscription, + SubscriptionStatus, +} from '../entities/user-subscription.entity'; import { InterestHistory } from '../entities/interest-history.entity'; import { SavingsProductType } from '../entities/savings-product.entity'; @@ -40,14 +43,18 @@ export class InterestCalculationService { const today = new Date(); today.setUTCHours(0, 0, 0, 0); - this.logger.log(`[runId=${runId}] Starting daily interest calculation for ${today.toISOString().split('T')[0]}`); + this.logger.log( + `[runId=${runId}] Starting daily interest calculation for ${today.toISOString().split('T')[0]}`, + ); const subscriptions = await this.subscriptionRepo.find({ where: { status: SubscriptionStatus.ACTIVE }, relations: ['product'], }); - this.logger.log(`[runId=${runId}] Found ${subscriptions.length} active subscriptions`); + this.logger.log( + `[runId=${runId}] Found ${subscriptions.length} active subscriptions`, + ); let processed = 0; let skipped = 0; @@ -55,7 +62,11 @@ export class InterestCalculationService { for (const subscription of subscriptions) { try { - const credited = await this.processSubscription(subscription, today, runId); + const credited = await this.processSubscription( + subscription, + today, + runId, + ); if (credited) { processed++; } else { @@ -111,21 +122,27 @@ export class InterestCalculationService { const daysInYear = this.getDaysInYear(calculationDate.getUTCFullYear()); // Daily simple interest: I = P * (r/365) * days - const dailyInterest = principal * (annualRate / 100 / daysInYear) * periodDays; + const dailyInterest = + principal * (annualRate / 100 / daysInYear) * periodDays; if (dailyInterest <= 0) { return false; } const interestEarned = parseFloat(dailyInterest.toFixed(7)); - const currentTotal = parseFloat(subscription.totalInterestEarned as unknown as string) || 0; + const currentTotal = + parseFloat(subscription.totalInterestEarned as unknown as string) || 0; const newTotal = parseFloat((currentTotal + interestEarned).toFixed(7)); await this.dataSource.transaction(async (manager) => { // Update subscription total interest - await manager.update(UserSubscription, { id: subscription.id }, { - totalInterestEarned: newTotal.toString(), - }); + await manager.update( + UserSubscription, + { id: subscription.id }, + { + totalInterestEarned: newTotal.toString(), + }, + ); // Insert audit record const record = manager.create(InterestHistory, { @@ -163,6 +180,6 @@ export class InterestCalculationService { /** Returns 366 for leap years, 365 otherwise. */ private getDaysInYear(year: number): number { - return (year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)) ? 366 : 365; + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 366 : 365; } } diff --git a/backend/src/modules/savings/services/milestone-rewards.service.ts b/backend/src/modules/savings/services/milestone-rewards.service.ts index cfe713ce6..153404221 100644 --- a/backend/src/modules/savings/services/milestone-rewards.service.ts +++ b/backend/src/modules/savings/services/milestone-rewards.service.ts @@ -29,7 +29,8 @@ export class MilestoneRewardsService { return; } - user.rewardPoints = Number(user.rewardPoints || 0) + Number(event.points || 0); + user.rewardPoints = + Number(user.rewardPoints || 0) + Number(event.points || 0); await this.userRepository.save(user); this.logger.log( diff --git a/backend/src/modules/savings/services/product-comparison.service.ts b/backend/src/modules/savings/services/product-comparison.service.ts index 69cca686d..610b9c3f4 100644 --- a/backend/src/modules/savings/services/product-comparison.service.ts +++ b/backend/src/modules/savings/services/product-comparison.service.ts @@ -57,7 +57,9 @@ export class ProductComparisonService { }); if (products.length !== uniqueProductIds.length) { - throw new NotFoundException('One or more savings products were not found'); + throw new NotFoundException( + 'One or more savings products were not found', + ); } const goals = await this.goalRepository.find({ where: { userId } }); diff --git a/backend/src/modules/savings/services/recommendation.service.ts b/backend/src/modules/savings/services/recommendation.service.ts index d91718979..5ede47d66 100644 --- a/backend/src/modules/savings/services/recommendation.service.ts +++ b/backend/src/modules/savings/services/recommendation.service.ts @@ -9,8 +9,14 @@ import { UserSubscription, SubscriptionStatus, } from '../entities/user-subscription.entity'; -import { SavingsGoal, SavingsGoalStatus } from '../entities/savings-goal.entity'; -import { Transaction, TxType } from '../../transactions/entities/transaction.entity'; +import { + SavingsGoal, + SavingsGoalStatus, +} from '../entities/savings-goal.entity'; +import { + Transaction, + TxType, +} from '../../transactions/entities/transaction.entity'; import { ProductRecommendationDto } from '../dto/recommendation-response.dto'; interface UserProfile { @@ -107,13 +113,13 @@ export class RecommendationService { 0, ); const activeSubscriptionTypes = [ - ...new Set(activeSubscriptions.map((s) => s.product?.type).filter(Boolean)), + ...new Set( + activeSubscriptions.map((s) => s.product?.type).filter(Boolean), + ), ] as SavingsProductType[]; // Risk tolerance: based on product mix and withdrawal history - const withdrawals = transactions.filter( - (t) => t.type === TxType.WITHDRAW, - ); + const withdrawals = transactions.filter((t) => t.type === TxType.WITHDRAW); const hasLockedProducts = activeSubscriptionTypes.includes( SavingsProductType.FIXED, ); @@ -153,10 +159,7 @@ export class RecommendationService { }; } - private scoreProduct( - product: SavingsProduct, - profile: UserProfile, - ): number { + private scoreProduct(product: SavingsProduct, profile: UserProfile): number { let score = 0.5; // base score // Risk alignment (+0.2) @@ -257,16 +260,16 @@ export class RecommendationService { profile: UserProfile, ): number { // Project based on user's average deposit or current invested amount - const principal = profile.avgTransactionAmount > 0 - ? profile.avgTransactionAmount - : Number(product.minAmount); + const principal = + profile.avgTransactionAmount > 0 + ? profile.avgTransactionAmount + : Number(product.minAmount); const rate = Number(product.interestRate) / 100; const months = product.tenureMonths || 12; // Compound interest: P * (1 + r/12)^months - P - const compounded = - principal * Math.pow(1 + rate / 12, months) - principal; + const compounded = principal * Math.pow(1 + rate / 12, months) - principal; return Math.max(0, compounded); }