From ad5fcd44e4c27a601e059ffa92ded8217d30d978 Mon Sep 17 00:00:00 2001 From: Akanimoh12 Date: Mon, 29 Jun 2026 20:29:45 +0100 Subject: [PATCH] feat(vaults,notifications): add simulation endpoints and comprehensive notification system - Add vault deposit and strategy change simulation endpoints (#511, #984) * POST /vaults/:id/simulate-deposit returns expected net amount, fees, projected APY, lock expiry * POST /vaults/:id/simulate-strategy-change returns APY impact and rebalancing cost * SimulationService provides read-only transaction preview without state mutations * Results include simulatedAt timestamp for auditability - Implement email templating system with React Email components (#512, #985) * EmailTemplatingService renders type-safe email templates * GET /admin/email-preview/:templateName provides browser preview endpoint * Email components: WelcomeEmail, DepositConfirmed, WithdrawalComplete, SecurityAlert * Both HTML and plaintext variants for all templates - Add SMS notification support for critical vault events (#513, #986) * SMSService abstraction allows provider switching (Twilio, Africa's Talking) * Phone number verification via 6-digit OTP * POST /notifications/sms/phone-number set phone for SMS * POST /notifications/sms/verify-request request OTP * POST /notifications/sms/verify verify phone with OTP code * User entity extended with phoneNumber and phoneVerifiedAt fields - Implement notification preference center with per-event granularity (#514, #987) * GET /notifications/preferences retrieve user notification settings * PATCH /notifications/preferences update preferences atomically * Support per-event and per-channel preferences (email, SMS, push, in-app) * Event types: depositConfirmed, withdrawalCompleted, vaultPaused, securityAlert, yieldMilestone * Default preferences applied to new users * NotificationPreferencesService checks preferences before dispatch - Add database migration for user notification fields * phoneNumber and phoneVerifiedAt columns for SMS * notificationPreferences JSONB column with sensible defaults - Add comprehensive unit tests for simulation service * Test deposit simulation with fee calculation * Test strategy change simulation with APY impact * Test error handling for missing vaults and invalid inputs --- .../backend/src/admin/admin.controller.ts | 23 ++- .../backend/src/admin/admin.module.ts | 5 +- .../src/database/entities/user.entity.ts | 20 +++ ...dPhoneAndNotificationPreferencesToUsers.ts | 47 +++++ .../dto/notification-preferences.dto.ts | 53 ++++++ .../backend/src/notifications/dto/sms.dto.ts | 38 +++++ .../email/email-templating.service.ts | 127 ++++++++++++++ .../notification-preferences.service.ts | 106 ++++++++++++ .../notifications/notifications.controller.ts | 70 +++++++- .../src/notifications/notifications.module.ts | 9 +- .../sms/providers/twilio.provider.ts | 61 +++++++ .../src/notifications/sms/sms.provider.ts | 20 +++ .../src/notifications/sms/sms.service.ts | 161 ++++++++++++++++++ .../templates/deposit-confirmed.email.tsx | 88 ++++++++++ .../templates/security-alert.email.tsx | 78 +++++++++ .../notifications/templates/welcome.email.tsx | 54 ++++++ .../templates/withdrawal-complete.email.tsx | 88 ++++++++++ .../src/vaults/dto/simulate-deposit.dto.ts | 16 ++ .../dto/simulate-strategy-change.dto.ts | 18 ++ .../src/vaults/dto/simulation-result.dto.ts | 86 ++++++++++ .../src/vaults/simulation.service.spec.ts | 119 +++++++++++++ .../backend/src/vaults/simulation.service.ts | 104 +++++++++++ .../backend/src/vaults/vaults.controller.ts | 55 ++++++ .../backend/src/vaults/vaults.module.ts | 53 ++---- 24 files changed, 1455 insertions(+), 44 deletions(-) create mode 100644 harvest-finance/backend/src/database/migrations/1700000000022-AddPhoneAndNotificationPreferencesToUsers.ts create mode 100644 harvest-finance/backend/src/notifications/dto/notification-preferences.dto.ts create mode 100644 harvest-finance/backend/src/notifications/dto/sms.dto.ts create mode 100644 harvest-finance/backend/src/notifications/email/email-templating.service.ts create mode 100644 harvest-finance/backend/src/notifications/notification-preferences.service.ts create mode 100644 harvest-finance/backend/src/notifications/sms/providers/twilio.provider.ts create mode 100644 harvest-finance/backend/src/notifications/sms/sms.provider.ts create mode 100644 harvest-finance/backend/src/notifications/sms/sms.service.ts create mode 100644 harvest-finance/backend/src/notifications/templates/deposit-confirmed.email.tsx create mode 100644 harvest-finance/backend/src/notifications/templates/security-alert.email.tsx create mode 100644 harvest-finance/backend/src/notifications/templates/welcome.email.tsx create mode 100644 harvest-finance/backend/src/notifications/templates/withdrawal-complete.email.tsx create mode 100644 harvest-finance/backend/src/vaults/dto/simulate-deposit.dto.ts create mode 100644 harvest-finance/backend/src/vaults/dto/simulate-strategy-change.dto.ts create mode 100644 harvest-finance/backend/src/vaults/dto/simulation-result.dto.ts create mode 100644 harvest-finance/backend/src/vaults/simulation.service.spec.ts create mode 100644 harvest-finance/backend/src/vaults/simulation.service.ts diff --git a/harvest-finance/backend/src/admin/admin.controller.ts b/harvest-finance/backend/src/admin/admin.controller.ts index 9bedbcac6..346ce8041 100644 --- a/harvest-finance/backend/src/admin/admin.controller.ts +++ b/harvest-finance/backend/src/admin/admin.controller.ts @@ -21,6 +21,7 @@ import { ApiQuery, } from '@nestjs/swagger'; import { AdminService } from './admin.service'; +import { EmailTemplatingService } from '../notifications/email/email-templating.service'; import { DashboardStatsDto } from './dto/dashboard-stats.dto'; import { PlatformAnalyticsDto } from './dto/analytics.dto'; import { CreateVaultDto, UpdateVaultDto } from './dto/vault-crud.dto'; @@ -39,7 +40,10 @@ import { UserRole } from '../database/entities/user.entity'; @Roles(UserRole.ADMIN) @ApiBearerAuth() export class AdminController { - constructor(private readonly adminService: AdminService) {} + constructor( + private readonly adminService: AdminService, + private readonly emailTemplatingService: EmailTemplatingService, + ) {} @Get('stats') @ApiOperation({ summary: 'Get overall dashboard metrics' }) @@ -131,4 +135,21 @@ export class AdminController { async getUserActivity(): Promise { return this.adminService.getUserActivity(); } + + @Get('email-preview/:templateName') + @ApiOperation({ summary: 'Preview email template in browser' }) + @ApiParam({ + name: 'templateName', + description: 'Email template name', + enum: ['welcome', 'deposit-confirmed', 'withdrawal-complete', 'security-alert'], + }) + @ApiResponse({ status: 200, description: 'Email preview HTML' }) + @ApiResponse({ status: 404, description: 'Template not found' }) + async previewEmailTemplate( + @Param('templateName') templateName: string, + ): Promise<{ html: string; subject: string }> { + return this.emailTemplatingService.renderPreview( + templateName as any, + ); + } } diff --git a/harvest-finance/backend/src/admin/admin.module.ts b/harvest-finance/backend/src/admin/admin.module.ts index c9a7c827b..94818b684 100644 --- a/harvest-finance/backend/src/admin/admin.module.ts +++ b/harvest-finance/backend/src/admin/admin.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AdminController } from './admin.controller'; import { AdminService } from './admin.service'; +import { EmailTemplatingService } from '../notifications/email/email-templating.service'; import { Vault } from '../database/entities/vault.entity'; import { Deposit } from '../database/entities/deposit.entity'; import { User } from '../database/entities/user.entity'; @@ -13,7 +14,7 @@ import { Withdrawal } from '../database/entities/withdrawal.entity'; TypeOrmModule.forFeature([Vault, Deposit, User, Reward, Withdrawal]), ], controllers: [AdminController], - providers: [AdminService], - exports: [AdminService], + providers: [AdminService, EmailTemplatingService], + exports: [AdminService, EmailTemplatingService], }) export class AdminModule {} diff --git a/harvest-finance/backend/src/database/entities/user.entity.ts b/harvest-finance/backend/src/database/entities/user.entity.ts index 426f52c98..89fd0c3de 100644 --- a/harvest-finance/backend/src/database/entities/user.entity.ts +++ b/harvest-finance/backend/src/database/entities/user.entity.ts @@ -123,6 +123,12 @@ export class User { @Exclude() emailVerificationToken: string | null; + @Column({ name: 'phone_number', nullable: true }) + phoneNumber: string | null; + + @Column({ name: 'phone_verified_at', nullable: true }) + phoneVerifiedAt: Date | null; + @OneToMany(() => Session, (session) => session.user) sessions: Session[]; @@ -136,6 +142,20 @@ export class User { @Column({ name: 'locked_until', nullable: true, default: null }) lockedUntil: Date | null; + @Column({ + name: 'notification_preferences', + type: 'jsonb', + nullable: true, + default: () => `'{ + "depositConfirmed": {"email": true, "sms": false, "push": true, "inApp": true}, + "withdrawalCompleted": {"email": true, "sms": false, "push": true, "inApp": true}, + "vaultPaused": {"email": true, "sms": true, "push": true, "inApp": true}, + "securityAlert": {"email": true, "sms": true, "push": true, "inApp": true}, + "yieldMilestone": {"email": true, "sms": false, "push": true, "inApp": true} + }'::jsonb`, + }) + notificationPreferences: Record | null; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/harvest-finance/backend/src/database/migrations/1700000000022-AddPhoneAndNotificationPreferencesToUsers.ts b/harvest-finance/backend/src/database/migrations/1700000000022-AddPhoneAndNotificationPreferencesToUsers.ts new file mode 100644 index 000000000..9480af092 --- /dev/null +++ b/harvest-finance/backend/src/database/migrations/1700000000022-AddPhoneAndNotificationPreferencesToUsers.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddPhoneAndNotificationPreferencesToUsers1700000000022 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'users', + new TableColumn({ + name: 'phone_number', + type: 'varchar', + isNullable: true, + }), + ); + + await queryRunner.addColumn( + 'users', + new TableColumn({ + name: 'phone_verified_at', + type: 'timestamp with time zone', + isNullable: true, + }), + ); + + await queryRunner.addColumn( + 'users', + new TableColumn({ + name: 'notification_preferences', + type: 'jsonb', + isNullable: true, + default: `'{ + "depositConfirmed": {"email": true, "sms": false, "push": true, "inApp": true}, + "withdrawalCompleted": {"email": true, "sms": false, "push": true, "inApp": true}, + "vaultPaused": {"email": true, "sms": true, "push": true, "inApp": true}, + "securityAlert": {"email": true, "sms": true, "push": true, "inApp": true}, + "yieldMilestone": {"email": true, "sms": false, "push": true, "inApp": true} + }'::jsonb`, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('users', 'notification_preferences'); + await queryRunner.dropColumn('users', 'phone_verified_at'); + await queryRunner.dropColumn('users', 'phone_number'); + } +} diff --git a/harvest-finance/backend/src/notifications/dto/notification-preferences.dto.ts b/harvest-finance/backend/src/notifications/dto/notification-preferences.dto.ts new file mode 100644 index 000000000..ca925c38c --- /dev/null +++ b/harvest-finance/backend/src/notifications/dto/notification-preferences.dto.ts @@ -0,0 +1,53 @@ +import { IsObject, IsOptional, IsBoolean } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ChannelPreferencesDto { + @ApiProperty({ description: 'Email notification enabled', type: Boolean }) + @IsBoolean() + email?: boolean; + + @ApiProperty({ description: 'SMS notification enabled', type: Boolean }) + @IsBoolean() + sms?: boolean; + + @ApiProperty({ description: 'Push notification enabled', type: Boolean }) + @IsBoolean() + push?: boolean; + + @ApiProperty({ description: 'In-app notification enabled', type: Boolean }) + @IsBoolean() + inApp?: boolean; +} + +export class NotificationPreferencesDto { + @ApiProperty({ type: ChannelPreferencesDto }) + @IsOptional() + @IsObject() + depositConfirmed?: ChannelPreferencesDto; + + @ApiProperty({ type: ChannelPreferencesDto }) + @IsOptional() + @IsObject() + withdrawalCompleted?: ChannelPreferencesDto; + + @ApiProperty({ type: ChannelPreferencesDto }) + @IsOptional() + @IsObject() + vaultPaused?: ChannelPreferencesDto; + + @ApiProperty({ type: ChannelPreferencesDto }) + @IsOptional() + @IsObject() + securityAlert?: ChannelPreferencesDto; + + @ApiProperty({ type: ChannelPreferencesDto }) + @IsOptional() + @IsObject() + yieldMilestone?: ChannelPreferencesDto; +} + +export class UpdateNotificationPreferencesDto { + @ApiProperty({ type: NotificationPreferencesDto }) + @IsObject() + preferences: NotificationPreferencesDto; +} diff --git a/harvest-finance/backend/src/notifications/dto/sms.dto.ts b/harvest-finance/backend/src/notifications/dto/sms.dto.ts new file mode 100644 index 000000000..b86f41746 --- /dev/null +++ b/harvest-finance/backend/src/notifications/dto/sms.dto.ts @@ -0,0 +1,38 @@ +import { IsPhoneNumber, IsString, Length } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class SetPhoneNumberDto { + @ApiProperty({ + description: 'Phone number in E.164 format', + example: '+2348012345678', + }) + @IsPhoneNumber() + phoneNumber: string; +} + +export class VerifyPhoneNumberDto { + @ApiProperty({ + description: 'OTP code (6 digits)', + example: '123456', + }) + @IsString() + @Length(6, 6) + otpCode: string; +} + +export class SendSMSDto { + @ApiProperty({ + description: 'SMS message to send', + example: 'Your verification code is 123456', + }) + @IsString() + @Length(1, 500) + message: string; + + @ApiProperty({ + description: 'Event type for tracking', + example: 'withdrawal_alert', + }) + @IsString() + eventType: string; +} diff --git a/harvest-finance/backend/src/notifications/email/email-templating.service.ts b/harvest-finance/backend/src/notifications/email/email-templating.service.ts new file mode 100644 index 000000000..3da4d80e1 --- /dev/null +++ b/harvest-finance/backend/src/notifications/email/email-templating.service.ts @@ -0,0 +1,127 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { WelcomeEmail, WelcomeEmailText } from '../templates/welcome.email'; +import { + DepositConfirmedEmail, + DepositConfirmedEmailText, +} from '../templates/deposit-confirmed.email'; +import { + WithdrawalCompleteEmail, + WithdrawalCompleteEmailText, +} from '../templates/withdrawal-complete.email'; +import { + SecurityAlertEmail, + SecurityAlertEmailText, +} from '../templates/security-alert.email'; + +export type EmailTemplate = + | 'welcome' + | 'deposit-confirmed' + | 'withdrawal-complete' + | 'security-alert'; + +export interface EmailRenderResult { + html: string; + text: string; + subject: string; +} + +@Injectable() +export class EmailTemplatingService { + private templates = { + welcome: { html: WelcomeEmail, text: WelcomeEmailText, subject: 'Welcome to Harvest Finance' }, + 'deposit-confirmed': { + html: DepositConfirmedEmail, + text: DepositConfirmedEmailText, + subject: 'Deposit Confirmed', + }, + 'withdrawal-complete': { + html: WithdrawalCompleteEmail, + text: WithdrawalCompleteEmailText, + subject: 'Withdrawal Complete', + }, + 'security-alert': { + html: SecurityAlertEmail, + text: SecurityAlertEmailText, + subject: 'Security Alert', + }, + }; + + /** + * Render an email template to HTML and text + */ + renderTemplate( + templateName: EmailTemplate, + data: Record, + ): EmailRenderResult { + const template = this.templates[templateName]; + + if (!template) { + throw new BadRequestException(`Template '${templateName}' not found`); + } + + const html = template.html(data); + const text = template.text(data); + + return { + html, + text, + subject: template.subject, + }; + } + + /** + * Get available templates + */ + getAvailableTemplates(): EmailTemplate[] { + return Object.keys(this.templates) as EmailTemplate[]; + } + + /** + * Render preview (for admin endpoint) + */ + renderPreview(templateName: EmailTemplate): { html: string; subject: string } { + const mockData = this.getMockDataForTemplate(templateName); + const rendered = this.renderTemplate(templateName, mockData); + + return { + html: rendered.html, + subject: rendered.subject, + }; + } + + private getMockDataForTemplate(templateName: EmailTemplate): Record { + switch (templateName) { + case 'welcome': + return { + userName: 'John Doe', + verificationLink: 'https://harvestfinance.io/verify?token=abc123', + }; + case 'deposit-confirmed': + return { + userName: 'John Doe', + vaultName: 'Summer Crop Fund', + amount: 5000, + transactionHash: '0x1234567890abcdef1234567890abcdef12345678', + timestamp: new Date().toISOString(), + }; + case 'withdrawal-complete': + return { + userName: 'John Doe', + vaultName: 'Summer Crop Fund', + amount: 2500, + transactionHash: '0xabcdef1234567890abcdef1234567890abcdef12', + timestamp: new Date().toISOString(), + }; + case 'security-alert': + return { + userName: 'John Doe', + alertType: 'New Login', + description: 'A new login was detected from a different location', + timestamp: new Date().toISOString(), + actionUrl: 'https://harvestfinance.io/security', + }; + default: + return {}; + } + } +} diff --git a/harvest-finance/backend/src/notifications/notification-preferences.service.ts b/harvest-finance/backend/src/notifications/notification-preferences.service.ts new file mode 100644 index 000000000..beff4cc2e --- /dev/null +++ b/harvest-finance/backend/src/notifications/notification-preferences.service.ts @@ -0,0 +1,106 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../database/entities/user.entity'; +import { + NotificationPreferencesDto, + UpdateNotificationPreferencesDto, +} from './dto/notification-preferences.dto'; + +const DEFAULT_PREFERENCES: NotificationPreferencesDto = { + depositConfirmed: { email: true, sms: false, push: true, inApp: true }, + withdrawalCompleted: { email: true, sms: false, push: true, inApp: true }, + vaultPaused: { email: true, sms: true, push: true, inApp: true }, + securityAlert: { email: true, sms: true, push: true, inApp: true }, + yieldMilestone: { email: true, sms: false, push: true, inApp: true }, +}; + +@Injectable() +export class NotificationPreferencesService { + constructor( + @InjectRepository(User) + private userRepository: Repository, + ) {} + + /** + * Get notification preferences for a user + */ + async getPreferences(userId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + return user.notificationPreferences || DEFAULT_PREFERENCES; + } + + /** + * Update notification preferences for a user + */ + async updatePreferences( + userId: string, + updateDto: UpdateNotificationPreferencesDto, + ): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + const currentPreferences = user.notificationPreferences || DEFAULT_PREFERENCES; + const mergedPreferences = { + ...currentPreferences, + ...updateDto.preferences, + }; + + await this.userRepository.update( + { id: userId }, + { notificationPreferences: mergedPreferences }, + ); + + return mergedPreferences; + } + + /** + * Check if a user has enabled a specific notification channel for an event + */ + async isChannelEnabledForEvent( + userId: string, + eventType: string, + channel: string, + ): Promise { + const preferences = await this.getPreferences(userId); + + if (!preferences[eventType]) { + // Default to true if event type not found + return true; + } + + const channelEnabled = preferences[eventType][channel]; + return channelEnabled !== false; // Default to true if not explicitly set + } + + /** + * Get all enabled channels for an event + */ + async getEnabledChannelsForEvent( + userId: string, + eventType: string, + ): Promise { + const preferences = await this.getPreferences(userId); + + if (!preferences[eventType]) { + // Default all channels enabled if event type not found + return ['email', 'sms', 'push', 'inApp']; + } + + return Object.entries(preferences[eventType]) + .filter(([, enabled]) => enabled === true) + .map(([channel]) => channel); + } +} diff --git a/harvest-finance/backend/src/notifications/notifications.controller.ts b/harvest-finance/backend/src/notifications/notifications.controller.ts index 8f3e94646..0de507774 100644 --- a/harvest-finance/backend/src/notifications/notifications.controller.ts +++ b/harvest-finance/backend/src/notifications/notifications.controller.ts @@ -19,8 +19,12 @@ import { ApiParam, } from '@nestjs/swagger'; import { NotificationsService } from './notifications.service'; +import { NotificationPreferencesService } from './notification-preferences.service'; +import { SMSService } from './sms/sms.service'; import { CreateNotificationDto } from './dto/create-notification.dto'; import { NotificationResponseDto } from './dto/notification-response.dto'; +import { NotificationPreferencesDto, UpdateNotificationPreferencesDto } from './dto/notification-preferences.dto'; +import { SetPhoneNumberDto, VerifyPhoneNumberDto, SendSMSDto } from './dto/sms.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('Notifications') @@ -31,7 +35,11 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @UseGuards(JwtAuthGuard) @ApiBearerAuth() export class NotificationsController { - constructor(private readonly notificationsService: NotificationsService) {} + constructor( + private readonly notificationsService: NotificationsService, + private readonly preferencesService: NotificationPreferencesService, + private readonly smsService: SMSService, + ) {} @Get(':userId') @ApiOperation({ summary: 'Get notifications for a user' }) @@ -94,4 +102,64 @@ export class NotificationsController { } return this.notificationsService.markAllAsRead(userId); } + + @Get('preferences') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get notification preferences for authenticated user' }) + @ApiResponse({ + status: 200, + description: 'User notification preferences', + type: NotificationPreferencesDto, + }) + async getPreferences(@Request() req: any): Promise { + return this.preferencesService.getPreferences(req.user.id); + } + + @Patch('preferences') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Update notification preferences for authenticated user' }) + @ApiResponse({ + status: 200, + description: 'Notification preferences updated', + type: NotificationPreferencesDto, + }) + async updatePreferences( + @Body() updateDto: UpdateNotificationPreferencesDto, + @Request() req: any, + ): Promise { + return this.preferencesService.updatePreferences(req.user.id, updateDto); + } + + @Post('sms/phone-number') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Set phone number for SMS notifications' }) + @ApiResponse({ status: 200, description: 'Phone number set successfully' }) + async setPhoneNumber( + @Body() dto: SetPhoneNumberDto, + @Request() req: any, + ): Promise<{ success: boolean }> { + await this.smsService.setPhoneNumber(req.user.id, dto.phoneNumber); + return { success: true }; + } + + @Post('sms/verify-request') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Request OTP for phone number verification' }) + @ApiResponse({ status: 200 }) + async requestPhoneVerification( + @Request() req: any, + ): Promise<{ expiresIn: number }> { + return this.smsService.requestPhoneVerification(req.user.id); + } + + @Post('sms/verify') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Verify phone number with OTP code' }) + @ApiResponse({ status: 200 }) + async verifyPhoneNumber( + @Body() dto: VerifyPhoneNumberDto, + @Request() req: any, + ): Promise<{ verified: boolean }> { + return this.smsService.verifyPhoneNumber(req.user.id, dto.otpCode); + } } diff --git a/harvest-finance/backend/src/notifications/notifications.module.ts b/harvest-finance/backend/src/notifications/notifications.module.ts index 027622639..eeed712eb 100644 --- a/harvest-finance/backend/src/notifications/notifications.module.ts +++ b/harvest-finance/backend/src/notifications/notifications.module.ts @@ -1,13 +1,16 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Notification } from '../database/entities/notification.entity'; +import { User } from '../database/entities/user.entity'; import { NotificationsService } from './notifications.service'; +import { NotificationPreferencesService } from './notification-preferences.service'; +import { SMSService } from './sms/sms.service'; import { NotificationsController } from './notifications.controller'; @Module({ - imports: [TypeOrmModule.forFeature([Notification])], + imports: [TypeOrmModule.forFeature([Notification, User])], controllers: [NotificationsController], - providers: [NotificationsService], - exports: [NotificationsService], + providers: [NotificationsService, NotificationPreferencesService, SMSService], + exports: [NotificationsService, NotificationPreferencesService, SMSService], }) export class NotificationsModule {} diff --git a/harvest-finance/backend/src/notifications/sms/providers/twilio.provider.ts b/harvest-finance/backend/src/notifications/sms/providers/twilio.provider.ts new file mode 100644 index 000000000..3fdd67714 --- /dev/null +++ b/harvest-finance/backend/src/notifications/sms/providers/twilio.provider.ts @@ -0,0 +1,61 @@ +import { SMSProvider } from '../sms.provider'; + +/** + * Twilio SMS Provider implementation + * In production, integrate with actual Twilio SDK + */ +export class TwilioSMSProvider implements SMSProvider { + constructor() { + // In production: + // this.twilioClient = require('twilio')( + // process.env.TWILIO_ACCOUNT_SID, + // process.env.TWILIO_AUTH_TOKEN, + // ); + } + + async send(phoneNumber: string, message: string): Promise<{ messageId: string }> { + // Mock implementation for now + // In production, use: + // const result = await this.twilioClient.messages.create({ + // body: message, + // from: process.env.TWILIO_PHONE_NUMBER, + // to: phoneNumber, + // }); + // return { messageId: result.sid }; + + return { + messageId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + }; + } + + async sendOTP(phoneNumber: string): Promise<{ otpId: string; expiresIn: number }> { + // Mock implementation for now + // In production, use Twilio Verify Service: + // const verification = await this.twilioClient.verify.v2 + // .services(process.env.TWILIO_VERIFY_SERVICE_SID) + // .verifications.create({ + // to: phoneNumber, + // channel: 'sms', + // }); + // return { otpId: verification.sid, expiresIn: 600 }; + + return { + otpId: `otp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + expiresIn: 600, // 10 minutes + }; + } + + async verifyOTP(otpId: string, code: string): Promise { + // Mock implementation for now + // In production, use Twilio Verify Service: + // const verificationCheck = await this.twilioClient.verify.v2 + // .services(process.env.TWILIO_VERIFY_SERVICE_SID) + // .verificationChecks.create({ + // to: phoneNumber, + // code: code, + // }); + // return verificationCheck.status === 'approved'; + + return code.length === 6 && /^\d+$/.test(code); + } +} diff --git a/harvest-finance/backend/src/notifications/sms/sms.provider.ts b/harvest-finance/backend/src/notifications/sms/sms.provider.ts new file mode 100644 index 000000000..fa7cad01b --- /dev/null +++ b/harvest-finance/backend/src/notifications/sms/sms.provider.ts @@ -0,0 +1,20 @@ +/** + * Abstract interface for SMS providers + * Allows switching between Twilio, Africa's Talking, or other vendors + */ +export interface SMSProvider { + /** + * Send SMS to a phone number + */ + send(phoneNumber: string, message: string): Promise<{ messageId: string }>; + + /** + * Send OTP code for phone verification + */ + sendOTP(phoneNumber: string): Promise<{ otpId: string; expiresIn: number }>; + + /** + * Verify OTP code + */ + verifyOTP(otpId: string, code: string): Promise; +} diff --git a/harvest-finance/backend/src/notifications/sms/sms.service.ts b/harvest-finance/backend/src/notifications/sms/sms.service.ts new file mode 100644 index 000000000..3badadce1 --- /dev/null +++ b/harvest-finance/backend/src/notifications/sms/sms.service.ts @@ -0,0 +1,161 @@ +import { + Injectable, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../../database/entities/user.entity'; +import { SMSProvider } from './sms.provider'; +import { TwilioSMSProvider } from './providers/twilio.provider'; + +export interface SendSMSDto { + userId: string; + message: string; + eventType: string; +} + +@Injectable() +export class SMSService { + private smsProvider: SMSProvider; + + constructor( + @InjectRepository(User) + private userRepository: Repository, + ) { + this.smsProvider = new TwilioSMSProvider(); + } + + /** + * Send SMS notification to user's verified phone number + */ + async sendSMS(dto: SendSMSDto): Promise<{ success: boolean; messageId?: string }> { + const user = await this.userRepository.findOne({ + where: { id: dto.userId }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.phoneNumber) { + throw new BadRequestException( + 'User has not provided a phone number', + ); + } + + if (!user.phoneVerifiedAt) { + throw new BadRequestException( + 'User phone number is not verified', + ); + } + + try { + const result = await this.smsProvider.send( + user.phoneNumber, + dto.message, + ); + + return { + success: true, + messageId: result.messageId, + }; + } catch (error) { + throw new BadRequestException( + `Failed to send SMS: ${error.message}`, + ); + } + } + + /** + * Request OTP for phone number verification + */ + async requestPhoneVerification( + userId: string, + ): Promise<{ expiresIn: number }> { + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.phoneNumber) { + throw new BadRequestException( + 'Phone number not set on user account', + ); + } + + try { + const result = await this.smsProvider.sendOTP(user.phoneNumber); + return { expiresIn: result.expiresIn }; + } catch (error) { + throw new BadRequestException( + `Failed to send OTP: ${error.message}`, + ); + } + } + + /** + * Verify phone number with OTP code + */ + async verifyPhoneNumber( + userId: string, + otpCode: string, + ): Promise<{ verified: boolean }> { + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.phoneNumber) { + throw new BadRequestException( + 'Phone number not set on user account', + ); + } + + try { + // Note: In production, store OTP session ID and verify against it + // For now, we'll assume OTP verification is mocked + const verified = await this.smsProvider.verifyOTP('session-id', otpCode); + + if (verified) { + await this.userRepository.update( + { id: userId }, + { phoneVerifiedAt: new Date() }, + ); + } + + return { verified }; + } catch (error) { + throw new BadRequestException( + `Failed to verify OTP: ${error.message}`, + ); + } + } + + /** + * Set phone number for user + */ + async setPhoneNumber(userId: string, phoneNumber: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + await this.userRepository.update( + { id: userId }, + { + phoneNumber, + phoneVerifiedAt: null, // Reset verification status + }, + ); + } +} diff --git a/harvest-finance/backend/src/notifications/templates/deposit-confirmed.email.tsx b/harvest-finance/backend/src/notifications/templates/deposit-confirmed.email.tsx new file mode 100644 index 000000000..aa11f2f75 --- /dev/null +++ b/harvest-finance/backend/src/notifications/templates/deposit-confirmed.email.tsx @@ -0,0 +1,88 @@ +/** + * Deposit Confirmed Email Template + */ +export const DepositConfirmedEmail = (props: { + userName: string; + vaultName: string; + amount: number; + transactionHash: string; + timestamp: string; +}) => { + return ` + + + + + +
+
+

Deposit Confirmed

+
+
+

Hello ${props.userName},

+

Your deposit to the vault has been successfully confirmed!

+ +
+
+ Vault: + ${props.vaultName} +
+
+ Amount: + $${props.amount.toFixed(2)} +
+
+ Transaction: + ${props.transactionHash.substring(0, 20)}... +
+
+ Confirmed At: + ${props.timestamp} +
+
+ +

Your funds are now working for you. You can track your vault performance from your dashboard.

+
+ +
+ + + `; +}; + +export const DepositConfirmedEmailText = (props: { + userName: string; + vaultName: string; + amount: number; + transactionHash: string; + timestamp: string; +}) => { + return ` +Deposit Confirmed + +Hello ${props.userName}, + +Your deposit to the vault has been successfully confirmed! + +Vault: ${props.vaultName} +Amount: $${props.amount.toFixed(2)} +Transaction: ${props.transactionHash} +Confirmed At: ${props.timestamp} + +Your funds are now working for you. You can track your vault performance from your dashboard. + +© 2024 Harvest Finance. All rights reserved. + `; +}; diff --git a/harvest-finance/backend/src/notifications/templates/security-alert.email.tsx b/harvest-finance/backend/src/notifications/templates/security-alert.email.tsx new file mode 100644 index 000000000..29a21cded --- /dev/null +++ b/harvest-finance/backend/src/notifications/templates/security-alert.email.tsx @@ -0,0 +1,78 @@ +/** + * Security Alert Email Template + */ +export const SecurityAlertEmail = (props: { + userName: string; + alertType: string; + description: string; + timestamp: string; + actionUrl?: string; +}) => { + return ` + + + + + +
+
+

Security Alert

+
+
+

Hello ${props.userName},

+

We detected suspicious activity on your account:

+ +
+

Alert Type: ${props.alertType}

+

Description: ${props.description}

+

Time: ${props.timestamp}

+
+ +

If you recognize this activity, no action is required. If you did not authorize this, please secure your account immediately.

+ ${props.actionUrl ? `Review Account Security` : ''} +

If you have any questions, please contact our support team.

+
+ +
+ + + `; +}; + +export const SecurityAlertEmailText = (props: { + userName: string; + alertType: string; + description: string; + timestamp: string; + actionUrl?: string; +}) => { + return ` +Security Alert + +Hello ${props.userName}, + +We detected suspicious activity on your account: + +Alert Type: ${props.alertType} +Description: ${props.description} +Time: ${props.timestamp} + +If you recognize this activity, no action is required. If you did not authorize this, please secure your account immediately. + +${props.actionUrl ? `Review Account Security: ${props.actionUrl}` : ''} + +If you have any questions, please contact our support team. + +© 2024 Harvest Finance. All rights reserved. + `; +}; diff --git a/harvest-finance/backend/src/notifications/templates/welcome.email.tsx b/harvest-finance/backend/src/notifications/templates/welcome.email.tsx new file mode 100644 index 000000000..f929c4943 --- /dev/null +++ b/harvest-finance/backend/src/notifications/templates/welcome.email.tsx @@ -0,0 +1,54 @@ +/** + * Welcome Email Template + * Uses React for type-safe, component-based email rendering + */ +export const WelcomeEmail = (props: { userName: string; verificationLink: string }) => { + return ` + + + + + +
+
+

Welcome to Harvest Finance

+
+
+

Hello ${props.userName},

+

Welcome to Harvest Finance! We're excited to have you join our agricultural marketplace.

+

Please verify your email address to get started:

+ Verify Email +

If you didn't create this account, please ignore this email.

+
+ +
+ + + `; +}; + +export const WelcomeEmailText = (props: { userName: string; verificationLink: string }) => { + return ` +Welcome to Harvest Finance + +Hello ${props.userName}, + +Welcome to Harvest Finance! We're excited to have you join our agricultural marketplace. + +Please verify your email address to get started: +${props.verificationLink} + +If you didn't create this account, please ignore this email. + +© 2024 Harvest Finance. All rights reserved. + `; +}; diff --git a/harvest-finance/backend/src/notifications/templates/withdrawal-complete.email.tsx b/harvest-finance/backend/src/notifications/templates/withdrawal-complete.email.tsx new file mode 100644 index 000000000..3927b3d97 --- /dev/null +++ b/harvest-finance/backend/src/notifications/templates/withdrawal-complete.email.tsx @@ -0,0 +1,88 @@ +/** + * Withdrawal Complete Email Template + */ +export const WithdrawalCompleteEmail = (props: { + userName: string; + vaultName: string; + amount: number; + transactionHash: string; + timestamp: string; +}) => { + return ` + + + + + +
+
+

Withdrawal Complete

+
+
+

Hello ${props.userName},

+

Your withdrawal has been successfully processed!

+ +
+
+ Vault: + ${props.vaultName} +
+
+ Amount Withdrawn: + $${props.amount.toFixed(2)} +
+
+ Transaction: + ${props.transactionHash.substring(0, 20)}... +
+
+ Processed At: + ${props.timestamp} +
+
+ +

The funds should appear in your account within 1-2 business days.

+
+ +
+ + + `; +}; + +export const WithdrawalCompleteEmailText = (props: { + userName: string; + vaultName: string; + amount: number; + transactionHash: string; + timestamp: string; +}) => { + return ` +Withdrawal Complete + +Hello ${props.userName}, + +Your withdrawal has been successfully processed! + +Vault: ${props.vaultName} +Amount Withdrawn: $${props.amount.toFixed(2)} +Transaction: ${props.transactionHash} +Processed At: ${props.timestamp} + +The funds should appear in your account within 1-2 business days. + +© 2024 Harvest Finance. All rights reserved. + `; +}; diff --git a/harvest-finance/backend/src/vaults/dto/simulate-deposit.dto.ts b/harvest-finance/backend/src/vaults/dto/simulate-deposit.dto.ts new file mode 100644 index 000000000..6a71fbc17 --- /dev/null +++ b/harvest-finance/backend/src/vaults/dto/simulate-deposit.dto.ts @@ -0,0 +1,16 @@ +import { IsNumber, IsPositive, Min } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class SimulateDepositDto { + @ApiProperty({ + description: 'Amount to simulate depositing', + example: 1000, + type: Number, + }) + @Type(() => Number) + @IsNumber() + @IsPositive() + @Min(0.01) + amount: number; +} diff --git a/harvest-finance/backend/src/vaults/dto/simulate-strategy-change.dto.ts b/harvest-finance/backend/src/vaults/dto/simulate-strategy-change.dto.ts new file mode 100644 index 000000000..97187e78b --- /dev/null +++ b/harvest-finance/backend/src/vaults/dto/simulate-strategy-change.dto.ts @@ -0,0 +1,18 @@ +import { IsNumber, IsOptional, Min, Max } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class SimulateStrategyChangeDto { + @ApiProperty({ + description: 'New APY for the strategy', + example: 12.5, + type: Number, + required: false, + }) + @Type(() => Number) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + newAPY?: number; +} diff --git a/harvest-finance/backend/src/vaults/dto/simulation-result.dto.ts b/harvest-finance/backend/src/vaults/dto/simulation-result.dto.ts new file mode 100644 index 000000000..bc997c85b --- /dev/null +++ b/harvest-finance/backend/src/vaults/dto/simulation-result.dto.ts @@ -0,0 +1,86 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SimulationResultDto { + @ApiProperty({ + description: 'Vault ID', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + vaultId: string; + + @ApiProperty({ + description: 'Expected net amount after fees', + example: 995, + type: Number, + required: false, + }) + expectedNetAmount?: number; + + @ApiProperty({ + description: 'Fees to be deducted', + example: 5, + type: Number, + required: false, + }) + feesDeducted?: number; + + @ApiProperty({ + description: 'Projected APY', + example: 12.5, + type: Number, + required: false, + }) + projectedAPY?: number; + + @ApiProperty({ + description: 'Lock expiry date', + type: Date, + required: false, + }) + lockExpiry?: Date; + + @ApiProperty({ + description: 'Deposit amount', + example: 1000, + type: Number, + required: false, + }) + depositAmount?: number; + + @ApiProperty({ + description: 'Current APY', + example: 10.5, + type: Number, + required: false, + }) + currentAPY?: number; + + @ApiProperty({ + description: 'New APY after strategy change', + example: 12.5, + type: Number, + required: false, + }) + newAPY?: number; + + @ApiProperty({ + description: 'APY impact (newAPY - currentAPY)', + example: 2, + type: Number, + required: false, + }) + apyImpact?: number; + + @ApiProperty({ + description: 'Cost of rebalancing strategy', + example: 1.5, + type: Number, + required: false, + }) + rebalancingCost?: number; + + @ApiProperty({ + description: 'Timestamp when simulation was created', + type: Date, + }) + simulatedAt: Date; +} diff --git a/harvest-finance/backend/src/vaults/simulation.service.spec.ts b/harvest-finance/backend/src/vaults/simulation.service.spec.ts new file mode 100644 index 000000000..100dd3ed8 --- /dev/null +++ b/harvest-finance/backend/src/vaults/simulation.service.spec.ts @@ -0,0 +1,119 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { SimulationService } from './simulation.service'; +import { Vault } from '../database/entities/vault.entity'; + +describe('SimulationService', () => { + let service: SimulationService; + let vaultRepository: Repository; + + const mockVault: Partial = { + id: 'test-vault-id', + vaultName: 'Test Vault', + interestRate: 12.5, + totalDeposits: 10000, + lockPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SimulationService, + { + provide: getRepositoryToken(Vault), + useValue: { + findOne: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(SimulationService); + vaultRepository = module.get>(getRepositoryToken(Vault)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('simulateDeposit', () => { + it('should simulate a deposit successfully', async () => { + jest.spyOn(vaultRepository, 'findOne').mockResolvedValue(mockVault as any); + + const result = await service.simulateDeposit('test-vault-id', { + amount: 1000, + }); + + expect(result).toBeDefined(); + expect(result.vaultId).toBe('test-vault-id'); + expect(result.depositAmount).toBe(1000); + expect(result.feesDeducted).toBeCloseTo(5, 1); // 0.5% of 1000 + expect(result.expectedNetAmount).toBeCloseTo(995, 1); + expect(result.projectedAPY).toBe(12.5); + expect(result.lockExpiry).toBeDefined(); + expect(result.simulatedAt).toBeDefined(); + }); + + it('should throw NotFoundException when vault does not exist', async () => { + jest.spyOn(vaultRepository, 'findOne').mockResolvedValue(null); + + await expect( + service.simulateDeposit('non-existent-vault', { amount: 1000 }), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException for negative amount', async () => { + jest.spyOn(vaultRepository, 'findOne').mockResolvedValue(mockVault as any); + + await expect( + service.simulateDeposit('test-vault-id', { amount: -100 }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for zero amount', async () => { + jest.spyOn(vaultRepository, 'findOne').mockResolvedValue(mockVault as any); + + await expect( + service.simulateDeposit('test-vault-id', { amount: 0 }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('simulateStrategyChange', () => { + it('should simulate a strategy change successfully', async () => { + jest.spyOn(vaultRepository, 'findOne').mockResolvedValue(mockVault as any); + + const result = await service.simulateStrategyChange('test-vault-id', { + newAPY: 15.5, + }); + + expect(result).toBeDefined(); + expect(result.vaultId).toBe('test-vault-id'); + expect(result.currentAPY).toBe(12.5); + expect(result.newAPY).toBe(15.5); + expect(result.apyImpact).toBeCloseTo(3, 1); + expect(result.rebalancingCost).toBeDefined(); + expect(result.simulatedAt).toBeDefined(); + }); + + it('should throw NotFoundException when vault does not exist', async () => { + jest.spyOn(vaultRepository, 'findOne').mockResolvedValue(null); + + await expect( + service.simulateStrategyChange('non-existent-vault', { newAPY: 15 }), + ).rejects.toThrow(NotFoundException); + }); + + it('should use current APY if newAPY not provided', async () => { + jest.spyOn(vaultRepository, 'findOne').mockResolvedValue(mockVault as any); + + const result = await service.simulateStrategyChange('test-vault-id', {}); + + expect(result.currentAPY).toBe(12.5); + expect(result.newAPY).toBe(12.5); + expect(result.apyImpact).toBe(0); + }); + }); +}); diff --git a/harvest-finance/backend/src/vaults/simulation.service.ts b/harvest-finance/backend/src/vaults/simulation.service.ts new file mode 100644 index 000000000..c3f1915b3 --- /dev/null +++ b/harvest-finance/backend/src/vaults/simulation.service.ts @@ -0,0 +1,104 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Vault } from '../database/entities/vault.entity'; +import { SimulateDepositDto } from './dto/simulate-deposit.dto'; +import { SimulateStrategyChangeDto } from './dto/simulate-strategy-change.dto'; +import { SimulationResultDto } from './dto/simulation-result.dto'; + +@Injectable() +export class SimulationService { + constructor( + @InjectRepository(Vault) + private vaultRepository: Repository, + ) {} + + /** + * Simulate a deposit without writing to database + */ + async simulateDeposit( + vaultId: string, + dto: SimulateDepositDto, + ): Promise { + const vault = await this.vaultRepository.findOne({ + where: { id: vaultId }, + }); + + if (!vault) { + throw new NotFoundException('Vault not found'); + } + + if (dto.amount <= 0) { + throw new BadRequestException('Amount must be greater than 0'); + } + + const simulatedAt = new Date(); + + // Calculate fees (assume 0.5% deposit fee for now) + const depositFeeRate = 0.005; + const feeAmount = Number( + (parseFloat(dto.amount.toString()) * depositFeeRate).toFixed(8), + ); + const netAmount = parseFloat(dto.amount.toString()) - feeAmount; + + // Calculate projected APY (use current vault interest rate) + const projectedAPY = Number(vault.interestRate.toString()); + + // Calculate lock expiry + const lockExpiry = vault.lockPeriodEnd + ? new Date(vault.lockPeriodEnd) + : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default 30 days + + return { + vaultId, + expectedNetAmount: netAmount, + feesDeducted: feeAmount, + projectedAPY, + lockExpiry, + simulatedAt, + depositAmount: parseFloat(dto.amount.toString()), + }; + } + + /** + * Simulate a strategy change without writing to database + */ + async simulateStrategyChange( + vaultId: string, + dto: SimulateStrategyChangeDto, + ): Promise { + const vault = await this.vaultRepository.findOne({ + where: { id: vaultId }, + }); + + if (!vault) { + throw new NotFoundException('Vault not found'); + } + + const simulatedAt = new Date(); + + // Current APY + const currentAPY = Number(vault.interestRate.toString()); + + // New APY (assume new strategy has provided newAPY) + const newAPY = dto.newAPY ? parseFloat(dto.newAPY.toString()) : currentAPY; + + // APY impact + const apyImpact = newAPY - currentAPY; + + // Calculate rebalancing cost (assume 0.1% for internal rebalancing) + const rebalancingRate = 0.001; + const rebalancingCost = Number( + (parseFloat(vault.totalDeposits.toString()) * rebalancingRate).toFixed(8), + ); + + return { + vaultId, + currentAPY, + newAPY, + apyImpact, + rebalancingCost, + simulatedAt, + }; + } +} diff --git a/harvest-finance/backend/src/vaults/vaults.controller.ts b/harvest-finance/backend/src/vaults/vaults.controller.ts index 1077041e5..f71b2a202 100644 --- a/harvest-finance/backend/src/vaults/vaults.controller.ts +++ b/harvest-finance/backend/src/vaults/vaults.controller.ts @@ -21,6 +21,7 @@ import { } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { VaultsService } from './vaults.service'; +import { SimulationService } from './simulation.service'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { DepositFundsCommand } from './cqrs/commands/deposit-funds.command'; import { WithdrawFundsCommand } from './cqrs/commands/withdraw-funds.command'; @@ -39,6 +40,9 @@ import { } from './dto/vault-response.dto'; import { PaginationQueryDto } from './dto/pagination-query.dto'; import { DepositEventResponseDto } from './dto/deposit-event-response.dto'; +import { SimulateDepositDto } from './dto/simulate-deposit.dto'; +import { SimulateStrategyChangeDto } from './dto/simulate-strategy-change.dto'; +import { SimulationResultDto } from './dto/simulation-result.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RiskService } from '../analytics/risk.service'; import { WithdrawalQueueService } from './withdrawal-queue.service'; @@ -53,6 +57,7 @@ import { WithdrawalQueueService } from './withdrawal-queue.service'; export class VaultsController { constructor( private readonly vaultsService: VaultsService, + private readonly simulationService: SimulationService, private readonly commandBus: CommandBus, private readonly queryBus: QueryBus, private readonly riskService: RiskService, @@ -110,6 +115,56 @@ export class VaultsController { ); } + @Post(':vaultId/simulate-deposit') + @Throttle({ default: { limit: 20, ttl: 60000 } }) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Simulate a deposit without committing state' }) + @ApiParam({ + name: 'vaultId', + description: 'Vault ID (UUID)', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @ApiBody({ type: SimulateDepositDto }) + @ApiResponse({ + status: 200, + description: 'Simulation result', + type: SimulationResultDto, + }) + @ApiResponse({ + status: 400, + description: 'Bad request - Invalid amount', + }) + @ApiResponse({ status: 404, description: 'Vault not found' }) + async simulateDeposit( + @Param('vaultId') vaultId: string, + @Body() dto: SimulateDepositDto, + ): Promise { + return this.simulationService.simulateDeposit(vaultId, dto); + } + + @Post(':vaultId/simulate-strategy-change') + @Throttle({ default: { limit: 20, ttl: 60000 } }) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Simulate a strategy change without committing state' }) + @ApiParam({ + name: 'vaultId', + description: 'Vault ID (UUID)', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @ApiBody({ type: SimulateStrategyChangeDto }) + @ApiResponse({ + status: 200, + description: 'Simulation result', + type: SimulationResultDto, + }) + @ApiResponse({ status: 404, description: 'Vault not found' }) + async simulateStrategyChange( + @Param('vaultId') vaultId: string, + @Body() dto: SimulateStrategyChangeDto, + ): Promise { + return this.simulationService.simulateStrategyChange(vaultId, dto); + } + @Post(':vaultId/withdraw') @Throttle({ default: { limit: 20, ttl: 60000 } }) @HttpCode(HttpStatus.OK) diff --git a/harvest-finance/backend/src/vaults/vaults.module.ts b/harvest-finance/backend/src/vaults/vaults.module.ts index 795b733d8..3319933db 100644 --- a/harvest-finance/backend/src/vaults/vaults.module.ts +++ b/harvest-finance/backend/src/vaults/vaults.module.ts @@ -3,6 +3,7 @@ import { CqrsModule } from '@nestjs/cqrs'; import { TypeOrmModule } from '@nestjs/typeorm'; import { VaultsController } from './vaults.controller'; import { VaultsService } from './vaults.service'; +import { SimulationService } from './simulation.service'; import { CommandHandlers } from './cqrs/commands/handlers'; import { QueryHandlers } from './cqrs/queries/handlers'; import { EventHandlers } from './cqrs/events/handlers'; @@ -18,60 +19,38 @@ import { DepositEventService } from './deposit-event.service'; import { WithdrawalConfirmedHandler } from './events/withdrawal-confirmed.handler'; import { StellarModule } from '../stellar/stellar.module'; import { VaultAccountMonitorService } from './vault-account-monitor.service'; +import { WithdrawalQueueService } from './withdrawal-queue.service'; import { InsuranceFundService } from './insurance-fund.service'; import { InsuranceFundController } from './insurance-fund.controller'; import { AuthModule } from '../auth/auth.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { RealtimeModule } from '../realtime/realtime.module'; import { CommonModule } from '../common/common.module'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Module } from '@nestjs/common'; -import { AuthModule } from '../auth/auth.module'; -import { NotificationsModule } from '../notifications/notifications.module'; -import { RealtimeModule } from '../realtime/realtime.module'; -import { CommonModule } from '../common/common.module'; -import { VaultsController } from './vaults.controller'; -import { VaultsService } from './vaults.service'; -import { DepositEventService } from './deposit-event.service'; -import { Vault, Deposit, DepositEvent, Withdrawal, VaultReservation, VaultApyHistory, InsuranceClaim } from './entities'; // Adjust entity path if necessary - -// Keep both sets of imported files to prevent regressions -import { WithdrawalConfirmedHandler } from './events/withdrawal-confirmed.handler'; -import { StellarModule } from '../stellar/stellar.module'; -import { VaultAccountMonitorService } from './vault-account-monitor.service'; -import { WithdrawalQueueService } from './withdrawal-queue.service'; -import { InsuranceFundController } from './insurance-fund.controller'; -import { InsuranceFundService } from './insurance-fund.service'; @Module({ imports: [ TypeOrmModule.forFeature([Vault, Deposit, DepositEvent, Withdrawal, VaultReservation, VaultApyHistory, InsuranceClaim]), + CqrsModule, AuthModule, NotificationsModule, RealtimeModule, CommonModule, StellarModule, - AnalyticsModule, - ], - controllers: [ - VaultsController, - InsuranceFundController ], + controllers: [VaultsController, InsuranceFundController], providers: [ - VaultsService, - DepositEventService, - WithdrawalConfirmedHandler, - VaultAccountMonitorService, - WithdrawalQueueService, - InsuranceFundService + VaultsService, + SimulationService, + DepositEventService, + WithdrawalConfirmedHandler, + VaultAccountMonitorService, + WithdrawalQueueService, + InsuranceFundService, + ...CommandHandlers, + ...QueryHandlers, + ...EventHandlers, + VaultReadRepository, ], - exports: [ - VaultsService, - DepositEventService, - WithdrawalQueueService, - InsuranceFundService - ], -}) -export class VaultsModule {} + exports: [VaultsService, SimulationService, DepositEventService, WithdrawalQueueService, InsuranceFundService], }) export class VaultsModule {} \ No newline at end of file