diff --git a/src/modules/audit/entities/unified-log.entity.ts b/src/modules/audit/entities/unified-log.entity.ts new file mode 100644 index 0000000..b385338 --- /dev/null +++ b/src/modules/audit/entities/unified-log.entity.ts @@ -0,0 +1,175 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +/** + * Unified log type enum - combines audit and activity + */ +export enum LogType { + // Audit actions (compliance/security) + CREATE = 'create', + UPDATE = 'update', + DELETE = 'delete', + READ = 'read', + LOGIN = 'login', + LOGOUT = 'logout', + EXPORT = 'export', + IMPORT = 'import', + + // Activity types (user engagement) + PAGE_VIEW = 'page_view', + FEATURE_USE = 'feature_use', + SEARCH = 'search', + DOWNLOAD = 'download', + UPLOAD = 'upload', + SHARE = 'share', + INVITE = 'invite', + SETTINGS_CHANGE = 'settings_change', + SUBSCRIPTION_CHANGE = 'subscription_change', + PAYMENT = 'payment', +} + +/** + * Log category enum - distinguishes between audit and activity + */ +export enum LogCategory { + AUDIT = 'audit', + ACTIVITY = 'activity', +} + +/** + * Audit severity enum - only applies to AUDIT category + */ +export enum AuditSeverity { + INFO = 'info', + WARNING = 'warning', + ERROR = 'error', + CRITICAL = 'critical', +} + +/** + * Actor type for logs + */ +export enum ActorType { + USER = 'user', + SYSTEM = 'system', + API_KEY = 'api_key', + WEBHOOK = 'webhook', +} + +/** + * UnifiedLog Entity + * Merges AuditLog and ActivityLog into a single entity + * Maps to audit.unified_logs DDL table + * + * Rationale: + * - Both entities track user actions with similar fields + * - Unified querying across all user actions + * - Simplified reporting and analytics + * - Category field distinguishes audit vs activity + */ +@Entity({ name: 'unified_logs', schema: 'audit' }) +@Index(['tenant_id', 'created_at']) +@Index(['category', 'log_type', 'created_at']) +@Index(['user_id', 'created_at']) +@Index(['resource_type', 'resource_id']) +@Index(['tenant_id', 'category', 'log_type', 'created_at']) +export class UnifiedLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + // Category and type + @Column({ + type: 'enum', + enum: LogCategory, + default: LogCategory.ACTIVITY, + }) + category: LogCategory; + + @Column({ + type: 'enum', + enum: LogType, + }) + log_type: LogType; + + // Actor information + @Column({ type: 'uuid', nullable: true }) + user_id: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + user_email: string | null; + + @Column({ type: 'varchar', length: 50, default: ActorType.USER }) + actor_type: string; + + // Resource identification + @Column({ type: 'varchar', length: 100 }) + resource_type: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + resource_id: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + resource_name: string | null; + + // Change tracking (only for AUDIT category) + @Column({ type: 'jsonb', nullable: true }) + old_values: Record | null; + + @Column({ type: 'jsonb', nullable: true }) + new_values: Record | null; + + @Column({ type: 'jsonb', nullable: true }) + changed_fields: string[] | null; + + // Severity (only for AUDIT category) + @Column({ + type: 'enum', + enum: AuditSeverity, + nullable: true, + }) + severity: AuditSeverity | null; + + // Context information + @Column({ type: 'inet', nullable: true }) + ip_address: string | null; + + @Column({ type: 'text', nullable: true }) + user_agent: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + request_id: string | null; + + @Column({ type: 'uuid', nullable: true }) + session_id: string | null; + + // HTTP context (for API audit logs) + @Column({ type: 'varchar', length: 255, nullable: true }) + endpoint: string | null; + + @Column({ type: 'varchar', length: 10, nullable: true }) + http_method: string | null; + + @Column({ type: 'smallint', nullable: true }) + response_status: number | null; + + @Column({ type: 'integer', nullable: true }) + duration_ms: number | null; + + // Description and metadata + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; +} diff --git a/src/modules/billing/__tests__/billing-usage.service.spec.ts b/src/modules/billing/__tests__/billing-usage.service.spec.ts new file mode 100644 index 0000000..223155b --- /dev/null +++ b/src/modules/billing/__tests__/billing-usage.service.spec.ts @@ -0,0 +1,380 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BillingService } from '../billing.service'; +import { Subscription, SubscriptionStatus } from '../entities/subscription.entity'; +import { Invoice, InvoiceStatus } from '../entities/invoice.entity'; +import { PaymentMethod } from '../entities/payment-method.entity'; +import { CreateSubscriptionDto } from '../dto/create-subscription.dto'; +import { UpdateSubscriptionDto } from '../dto/update-subscription.dto'; + +describe('BillingService', () => { + let service: BillingService; + let subscriptionRepo: Repository; + let invoiceRepo: Repository; + let paymentMethodRepo: Repository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BillingService, + { + provide: getRepositoryToken(Subscription), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, + { + provide: getRepositoryToken(Invoice), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, + { + provide: getRepositoryToken(PaymentMethod), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(BillingService); + subscriptionRepo = module.get>(getRepositoryToken(Subscription)); + invoiceRepo = module.get>(getRepositoryToken(Invoice)); + paymentMethodRepo = module.get>(getRepositoryToken(PaymentMethod)); + }); + + describe('createSubscription', () => { + it('should create a trial subscription', async () => { + const dto: CreateSubscriptionDto = { + tenant_id: 'tenant-123', + plan_id: 'plan-456', + trial_end: '2026-02-20', + payment_provider: 'stripe', + }; + + const expectedSubscription = { + tenant_id: dto.tenant_id, + plan_id: dto.plan_id, + status: SubscriptionStatus.TRIALING, + current_period_start: expect.any(Date), + current_period_end: expect.any(Date), + trial_end: new Date(dto.trial_end), + payment_provider: dto.payment_provider, + }; + + jest.spyOn(subscriptionRepo, 'create').mockReturnValue(expectedSubscription as any); + jest.spyOn(subscriptionRepo, 'save').mockResolvedValue(expectedSubscription as any); + + const result = await service.createSubscription(dto); + + expect(subscriptionRepo.create).toHaveBeenCalledWith(expectedSubscription); + expect(subscriptionRepo.save).toHaveBeenCalledWith(expectedSubscription); + expect(result).toEqual(expectedSubscription); + }); + + it('should create an active subscription', async () => { + const dto: CreateSubscriptionDto = { + tenant_id: 'tenant-123', + plan_id: 'plan-456', + payment_provider: 'stripe', + }; + + const expectedSubscription = { + tenant_id: dto.tenant_id, + plan_id: dto.plan_id, + status: SubscriptionStatus.ACTIVE, + current_period_start: expect.any(Date), + current_period_end: expect.any(Date), + trial_end: null, + payment_provider: dto.payment_provider, + }; + + jest.spyOn(subscriptionRepo, 'create').mockReturnValue(expectedSubscription as any); + jest.spyOn(subscriptionRepo, 'save').mockResolvedValue(expectedSubscription as any); + + const result = await service.createSubscription(dto); + + expect(subscriptionRepo.create).toHaveBeenCalledWith(expectedSubscription); + expect(subscriptionRepo.save).toHaveBeenCalledWith(expectedSubscription); + expect(result).toEqual(expectedSubscription); + }); + }); + + describe('getSubscription', () => { + it('should return subscription for tenant', async () => { + const tenantId = 'tenant-123'; + const subscription = { + id: 'sub-123', + tenant_id: tenantId, + status: SubscriptionStatus.ACTIVE, + }; + + jest.spyOn(subscriptionRepo, 'findOne').mockResolvedValue(subscription as any); + + const result = await service.getSubscription(tenantId); + + expect(subscriptionRepo.findOne).toHaveBeenCalledWith({ + where: { tenant_id: tenantId }, + }); + expect(result).toEqual(subscription); + }); + + it('should return null if subscription not found', async () => { + const tenantId = 'tenant-123'; + + jest.spyOn(subscriptionRepo, 'findOne').mockResolvedValue(null); + + const result = await service.getSubscription(tenantId); + + expect(subscriptionRepo.findOne).toHaveBeenCalledWith({ + where: { tenant_id: tenantId }, + }); + expect(result).toBeNull(); + }); + }); + + describe('updateSubscription', () => { + it('should update subscription status', async () => { + const tenantId = 'tenant-123'; + const dto: UpdateSubscriptionDto = { + status: SubscriptionStatus.CANCELLED, + }; + + const existingSubscription = { + id: 'sub-123', + tenant_id: tenantId, + status: SubscriptionStatus.ACTIVE, + }; + + const updatedSubscription = { + ...existingSubscription, + status: SubscriptionStatus.CANCELLED, + }; + + jest.spyOn(subscriptionRepo, 'findOne').mockResolvedValue(existingSubscription as any); + jest.spyOn(subscriptionRepo, 'save').mockResolvedValue(updatedSubscription as any); + + const result = await service.updateSubscription(tenantId, dto); + + expect(subscriptionRepo.findOne).toHaveBeenCalledWith({ + where: { tenant_id: tenantId }, + }); + expect(subscriptionRepo.save).toHaveBeenCalledWith(updatedSubscription); + expect(result).toEqual(updatedSubscription); + }); + + it('should throw NotFoundException if subscription not found', async () => { + const tenantId = 'tenant-123'; + const dto: UpdateSubscriptionDto = { + status: SubscriptionStatus.CANCELLED, + }; + + jest.spyOn(subscriptionRepo, 'findOne').mockResolvedValue(null); + + await expect(service.updateSubscription(tenantId, dto)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('cancelSubscription', () => { + it('should cancel subscription and create final invoice', async () => { + const tenantId = 'tenant-123'; + const dto = { + reason: 'Customer request', + refund_amount: 50.00, + }; + + const existingSubscription = { + id: 'sub-123', + tenant_id: tenantId, + status: SubscriptionStatus.ACTIVE, + plan_id: 'plan-456', + }; + + const cancelledSubscription = { + ...existingSubscription, + status: SubscriptionStatus.CANCELLED, + cancelled_at: expect.any(Date), + cancellation_reason: dto.reason, + }; + + const finalInvoice = { + tenant_id: tenantId, + subscription_id: existingSubscription.id, + status: InvoiceStatus.DRAFT, + total: dto.refund_amount, + type: 'refund', + }; + + jest.spyOn(subscriptionRepo, 'findOne').mockResolvedValue(existingSubscription as any); + jest.spyOn(subscriptionRepo, 'save').mockResolvedValue(cancelledSubscription as any); + jest.spyOn(invoiceRepo, 'create').mockReturnValue(finalInvoice as any); + jest.spyOn(invoiceRepo, 'save').mockResolvedValue(finalInvoice as any); + + const result = await service.cancelSubscription(tenantId, dto); + + expect(subscriptionRepo.findOne).toHaveBeenCalledWith({ + where: { tenant_id: tenantId }, + }); + expect(subscriptionRepo.save).toHaveBeenCalledWith(cancelledSubscription); + expect(invoiceRepo.create).toHaveBeenCalledWith(finalInvoice); + expect(invoiceRepo.save).toHaveBeenCalledWith(finalInvoice); + expect(result).toEqual(cancelledSubscription); + }); + }); + + describe('calculateUsage', () => { + it('should calculate monthly usage for subscription', async () => { + const tenantId = 'tenant-123'; + const subscriptionId = 'sub-123'; + const startDate = new Date('2026-01-01'); + const endDate = new Date('2026-01-31'); + + const mockUsage = [ + { metric: 'api_calls', value: 1000, unit: 'count' }, + { metric: 'storage_gb', value: 50, unit: 'gb' }, + { metric: 'users', value: 25, unit: 'count' }, + ]; + + // Mock usage records query + jest.spyOn(subscriptionRepo, 'findOne').mockResolvedValue({ + id: subscriptionId, + tenant_id: tenantId, + } as any); + + // This would typically query a usage table + // For now, we'll mock the calculation + const expectedUsage = { + period: { + start: startDate, + end: endDate, + }, + metrics: mockUsage, + total_cost: 150.00, + }; + + // Assuming service has a calculateUsage method + // If not, this test would need to be adjusted + const result = await service['calculateUsage']?.(tenantId, subscriptionId, startDate, endDate); + + // For demonstration, we'll test the structure + expect(result).toBeDefined(); + expect(result.period.start).toEqual(startDate); + expect(result.period.end).toEqual(endDate); + expect(result.metrics).toHaveLength(3); + }); + }); + + describe('getInvoices', () => { + it('should return paginated invoices for tenant', async () => { + const tenantId = 'tenant-123'; + const pagination = { + page: 1, + limit: 10, + }; + + const mockInvoices = [ + { id: 'inv-1', tenant_id: tenantId, total: 100.00 }, + { id: 'inv-2', tenant_id: tenantId, total: 50.00 }, + ]; + + jest.spyOn(invoiceRepo, 'find').mockResolvedValue(mockInvoices as any); + jest.spyOn(invoiceRepo, 'count').mockResolvedValue(2); + + const result = await service.getInvoices(tenantId, pagination); + + expect(invoiceRepo.find).toHaveBeenCalledWith({ + where: { tenant_id: tenantId }, + order: { created_at: 'DESC' }, + skip: 0, + take: 10, + }); + expect(invoiceRepo.count).toHaveBeenCalledWith({ + where: { tenant_id: tenantId }, + }); + expect(result).toEqual({ + invoices: mockInvoices, + total: 2, + page: 1, + limit: 10, + }); + }); + }); + + describe('processPayment', () => { + it('should process successful payment and update invoice', async () => { + const invoiceId = 'inv-123'; + const paymentData = { + amount: 100.00, + method: 'card', + transaction_id: 'txn-456', + }; + + const existingInvoice = { + id: invoiceId, + status: InvoiceStatus.PENDING, + total: 100.00, + paid_amount: 0, + }; + + const updatedInvoice = { + ...existingInvoice, + status: InvoiceStatus.PAID, + paid_amount: 100.00, + paid_at: expect.any(Date), + payment_method: paymentData.method, + transaction_id: paymentData.transaction_id, + }; + + jest.spyOn(invoiceRepo, 'findOne').mockResolvedValue(existingInvoice as any); + jest.spyOn(invoiceRepo, 'save').mockResolvedValue(updatedInvoice as any); + + const result = await service.processPayment(invoiceId, paymentData); + + expect(invoiceRepo.findOne).toHaveBeenCalledWith({ + where: { id: invoiceId }, + }); + expect(invoiceRepo.save).toHaveBeenCalledWith(updatedInvoice); + expect(result).toEqual(updatedInvoice); + }); + + it('should throw error if invoice already paid', async () => { + const invoiceId = 'inv-123'; + const paymentData = { + amount: 100.00, + method: 'card', + }; + + const existingInvoice = { + id: invoiceId, + status: InvoiceStatus.PAID, + total: 100.00, + paid_amount: 100.00, + }; + + jest.spyOn(invoiceRepo, 'findOne').mockResolvedValue(existingInvoice as any); + + await expect(service.processPayment(invoiceId, paymentData)).rejects.toThrow( + BadRequestException, + ); + }); + }); +}); diff --git a/src/modules/feature-flags/entities/flag-override.entity.ts b/src/modules/feature-flags/entities/flag-override.entity.ts new file mode 100644 index 0000000..8e1c0c5 --- /dev/null +++ b/src/modules/feature-flags/entities/flag-override.entity.ts @@ -0,0 +1,74 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { FeatureFlag } from './feature-flag.entity'; + +/** + * Target type enum for flag overrides + */ +export enum FlagTargetType { + USER = 'user', + TENANT = 'tenant', +} + +/** + * FlagOverride Entity + * Merges UserFlag and TenantFlag into a single entity + * Maps to feature_flags.flag_overrides DDL table + * + * Rationale: + * - Both entities represent flag overrides with identical structure + * - Simplified flag evaluation logic + * - Single query for all overrides + * - target_type field distinguishes user vs tenant overrides + */ +@Entity({ name: 'flag_overrides', schema: 'feature_flags' }) +@Index(['target_type', 'target_id', 'flag_id'], { unique: true }) +@Index(['tenant_id', 'target_type']) +export class FlagOverride { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + // Target identification + @Column({ + type: 'enum', + enum: FlagTargetType, + }) + target_type: FlagTargetType; + + @Column({ type: 'uuid' }) + target_id: string; // user_id or tenant_id based on target_type + + @Column({ type: 'uuid' }) + flag_id: string; + + @ManyToOne(() => FeatureFlag, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'flag_id' }) + flag: FeatureFlag; + + // Flag value + @Column({ type: 'boolean', default: true }) + is_enabled: boolean; + + @Column({ type: 'jsonb', nullable: true }) + value: any; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; +} diff --git a/src/modules/notifications/adapters/channel-adapter.interface.ts b/src/modules/notifications/adapters/channel-adapter.interface.ts new file mode 100644 index 0000000..78957ce --- /dev/null +++ b/src/modules/notifications/adapters/channel-adapter.interface.ts @@ -0,0 +1,186 @@ +import { Injectable, Logger } from '@nestjs/common'; + +/** + * Channel Adapter Interface + * Defines contract for all notification channels + */ +export interface INotificationChannel { + /** + * Unique identifier for the channel + */ + readonly channelType: string; + + /** + * Send notification through this channel + * @param notification Notification data + * @param preferences User preferences for this channel + * @returns Delivery result + */ + send(notification: any, preferences?: any): Promise; + + /** + * Validate if channel is available + * @returns True if channel can send messages + */ + isAvailable(): Promise; + + /** + * Get channel configuration schema + * @returns JSON schema for channel config + */ + getConfigSchema(): Record; +} + +/** + * Notification delivery result + */ +export interface NotificationDeliveryResult { + success: boolean; + messageId?: string; + error?: string; + metadata?: Record; +} + +/** + * Channel Adapter Base Class + * Provides common functionality for all channels + */ +@Injectable() +export abstract class BaseNotificationAdapter implements INotificationChannel { + protected readonly logger: Logger; + + constructor(channelType: string) { + this.logger = new Logger(`${channelType}Adapter`); + } + + abstract readonly channelType: string; + abstract send(notification: any, preferences?: any): Promise; + abstract isAvailable(): Promise; + abstract getConfigSchema(): Record; + + /** + * Transform notification to channel-specific format + */ + protected abstract transform(notification: any): any; + + /** + * Validate notification data + */ + protected validate(notification: any): boolean { + return !!(notification.title && notification.message); + } + + /** + * Log delivery attempt + */ + protected logAttempt(notification: any, result: NotificationDeliveryResult): void { + if (result.success) { + this.logger.log( + `Notification sent via ${this.channelType}: ${result.messageId}`, + ); + } else { + this.logger.error( + `Failed to send via ${this.channelType}: ${result.error}`, + ); + } + } +} + +/** + * Channel Registry + * Manages all available notification channels + */ +@Injectable() +export class NotificationChannelRegistry { + private readonly channels = new Map(); + + /** + * Register a new channel + */ + register(channel: INotificationChannel): void { + this.channels.set(channel.channelType, channel); + } + + /** + * Get channel by type + */ + get(channelType: string): INotificationChannel | undefined { + return this.channels.get(channelType); + } + + /** + * Get all registered channels + */ + getAll(): INotificationChannel[] { + return Array.from(this.channels.values()); + } + + /** + * Get available channels + */ + async getAvailable(): Promise { + const channels = await Promise.all( + Array.from(this.channels.values()).map(async (channel) => ({ + channel, + available: await channel.isAvailable(), + })), + ); + + return channels + .filter(({ available }) => available) + .map(({ channel }) => channel); + } +} + +/** + * Channel Manager + * Orchestrates notification delivery across multiple channels + */ +@Injectable() +export class NotificationChannelManager { + constructor(private readonly registry: NotificationChannelRegistry) {} + + /** + * Send notification through preferred channels + */ + async send( + notification: any, + preferredChannels: string[] = [], + fallbackChannels: string[] = ['in_app'], + ): Promise { + const results: NotificationDeliveryResult[] = []; + const channels = await this.registry.getAvailable(); + + // Try preferred channels first + for (const channelType of preferredChannels) { + const channel = channels.find((c) => c.channelType === channelType); + if (channel) { + const result = await channel.send(notification); + results.push(result); + } + } + + // If no preferred channels worked, try fallbacks + if (!results.some((r) => r.success)) { + for (const channelType of fallbackChannels) { + const channel = channels.find((c) => c.channelType === channelType); + if (channel) { + const result = await channel.send(notification); + results.push(result); + if (result.success) break; // Stop at first successful fallback + } + } + } + + return results; + } + + /** + * Send through all available channels + */ + async sendToAll(notification: any): Promise { + const channels = await this.registry.getAvailable(); + const promises = channels.map((channel) => channel.send(notification)); + return Promise.all(promises); + } +} diff --git a/src/modules/notifications/adapters/channel-adapters.service.ts b/src/modules/notifications/adapters/channel-adapters.service.ts new file mode 100644 index 0000000..d0d38dc --- /dev/null +++ b/src/modules/notifications/adapters/channel-adapters.service.ts @@ -0,0 +1,330 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { BaseNotificationAdapter, NotificationDeliveryResult } from './channel-adapter.interface'; + +/** + * Email Channel Adapter + * Handles email notifications + */ +@Injectable() +export class EmailChannelAdapter extends BaseNotificationAdapter { + readonly channelType = 'email'; + + constructor(private readonly emailService: any) { + super('email'); + } + + async isAvailable(): Promise { + try { + // Check email service configuration + return !!(this.emailService && await this.emailService.isConfigured?.()); + } catch { + return false; + } + } + + getConfigSchema(): Record { + return { + type: 'object', + properties: { + smtp: { + type: 'object', + properties: { + host: { type: 'string' }, + port: { type: 'number' }, + secure: { type: 'boolean' }, + auth: { + type: 'object', + properties: { + user: { type: 'string' }, + pass: { type: 'string' }, + }, + }, + }, + }, + }, + }; + } + + protected transform(notification: any): any { + return { + to: notification.recipient, + subject: notification.title, + text: notification.message, + html: notification.html || notification.message, + attachments: notification.attachments || [], + }; + } + + async send(notification: any, preferences?: any): Promise { + try { + if (!this.validate(notification)) { + return { + success: false, + error: 'Invalid notification data', + }; + } + + const emailData = this.transform(notification); + const result = await this.emailService.sendMail(emailData); + + const deliveryResult: NotificationDeliveryResult = { + success: true, + messageId: result.messageId, + metadata: { + provider: 'smtp', + ...result, + }, + }; + + this.logAttempt(notification, deliveryResult); + return deliveryResult; + } catch (error) { + const deliveryResult: NotificationDeliveryResult = { + success: false, + error: error.message, + }; + + this.logAttempt(notification, deliveryResult); + return deliveryResult; + } + } +} + +/** + * Push Notification Channel Adapter + * Handles mobile/web push notifications + */ +@Injectable() +export class PushChannelAdapter extends BaseNotificationAdapter { + readonly channelType = 'push'; + + constructor(private readonly pushService: any) { + super('push'); + } + + async isAvailable(): Promise { + try { + return !!(this.pushService && await this.pushService.isConfigured?.()); + } catch { + return false; + } + } + + getConfigSchema(): Record { + return { + type: 'object', + properties: { + fcm: { + type: 'object', + properties: { + serverKey: { type: 'string' }, + }, + }, + apns: { + type: 'object', + properties: { + keyId: { type: 'string' }, + teamId: { type: 'string' }, + bundleId: { type: 'string' }, + }, + }, + }, + }; + } + + protected transform(notification: any): any { + return { + title: notification.title, + body: notification.message, + icon: notification.icon, + image: notification.image, + data: notification.data || {}, + actions: notification.actions || [], + badge: notification.badge, + sound: notification.sound || 'default', + }; + } + + async send(notification: any, preferences?: any): Promise { + try { + if (!notification.deviceToken) { + return { + success: false, + error: 'Device token required for push notifications', + }; + } + + const pushData = this.transform(notification); + const result = await this.pushService.send(notification.deviceToken, pushData); + + const deliveryResult: NotificationDeliveryResult = { + success: true, + messageId: result.messageId, + metadata: { + provider: 'fcm/apns', + deviceToken: notification.deviceToken, + ...result, + }, + }; + + this.logAttempt(notification, deliveryResult); + return deliveryResult; + } catch (error) { + const deliveryResult: NotificationDeliveryResult = { + success: false, + error: error.message, + }; + + this.logAttempt(notification, deliveryResult); + return deliveryResult; + } + } +} + +/** + * WhatsApp Channel Adapter + * Handles WhatsApp Business API notifications + */ +@Injectable() +export class WhatsAppChannelAdapter extends BaseNotificationAdapter { + readonly channelType = 'whatsapp'; + + constructor(private readonly whatsappService: any) { + super('whatsapp'); + } + + async isAvailable(): Promise { + try { + return !!(this.whatsappService && await this.whatsappService.isConfigured?.()); + } catch { + return false; + } + } + + getConfigSchema(): Record { + return { + type: 'object', + properties: { + accessToken: { type: 'string' }, + phoneNumberId: { type: 'string' }, + version: { type: 'string' }, + webhookVerifyToken: { type: 'string' }, + }, + }; + } + + protected transform(notification: any): any { + return { + to: notification.recipient, + type: notification.template ? 'template' : 'text', + text: { body: notification.message }, + template: notification.template, + }; + } + + async send(notification: any, preferences?: any): Promise { + try { + if (!notification.recipient) { + return { + success: false, + error: 'Recipient required for WhatsApp notifications', + }; + } + + const whatsappData = this.transform(notification); + const result = await this.whatsappService.sendMessage(whatsappData); + + const deliveryResult: NotificationDeliveryResult = { + success: true, + messageId: result.messageId, + metadata: { + provider: 'whatsapp', + recipient: notification.recipient, + ...result, + }, + }; + + this.logAttempt(notification, deliveryResult); + return deliveryResult; + } catch (error) { + const deliveryResult: NotificationDeliveryResult = { + success: false, + error: error.message, + }; + + this.logAttempt(notification, deliveryResult); + return deliveryResult; + } + } +} + +/** + * In-App Channel Adapter + * Handles in-app notifications (stored in database) + */ +@Injectable() +export class InAppChannelAdapter extends BaseNotificationAdapter { + readonly channelType = 'in_app'; + + constructor(private readonly notificationRepository: any) { + super('in_app'); + } + + async isAvailable(): Promise { + return true; // In-app is always available if DB is accessible + } + + getConfigSchema(): Record { + return { + type: 'object', + properties: { + retention: { + type: 'object', + properties: { + days: { type: 'number', default: 30 }, + }, + }, + }, + }; + } + + protected transform(notification: any): any { + return { + user_id: notification.userId, + tenant_id: notification.tenantId, + type: notification.type || 'info', + title: notification.title, + message: notification.message, + data: notification.data || null, + action_url: notification.actionUrl || null, + delivery_status: 'delivered', + }; + } + + async send(notification: any, preferences?: any): Promise { + try { + const notificationData = this.transform(notification); + const saved = await this.notificationRepository.save(notificationData); + + const deliveryResult: NotificationDeliveryResult = { + success: true, + messageId: saved.id, + metadata: { + provider: 'database', + notificationId: saved.id, + }, + }; + + this.logAttempt(notification, deliveryResult); + return deliveryResult; + } catch (error) { + const deliveryResult: NotificationDeliveryResult = { + success: false, + error: error.message, + }; + + this.logAttempt(notification, deliveryResult); + return deliveryResult; + } + } +} diff --git a/src/modules/webhooks/__tests__/webhook-retry.spec.ts b/src/modules/webhooks/__tests__/webhook-retry.spec.ts new file mode 100644 index 0000000..121290f --- /dev/null +++ b/src/modules/webhooks/__tests__/webhook-retry.spec.ts @@ -0,0 +1,435 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { getQueueToken } from '@nestjs/bullmq'; +import { Repository } from 'typeorm'; +import { Queue } from 'bullmq'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { WebhookService } from '../services/webhook.service'; +import { WebhookEntity, WebhookDeliveryEntity, DeliveryStatus } from '../entities'; + +describe('WebhookService - Retry Logic', () => { + let service: WebhookService; + let webhookRepo: jest.Mocked>; + let deliveryRepo: jest.Mocked>; + let webhookQueue: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockWebhook: Partial = { + id: 'webhook-001', + tenantId: mockTenantId, + name: 'Test Webhook', + description: 'Test webhook description', + url: 'https://example.com/webhook', + events: ['user.created', 'user.updated'], + headers: { 'X-Custom': 'header' }, + secret: 'whsec_testsecret123', + isActive: true, + retryPolicy: { + maxAttempts: 3, + backoffStrategy: 'exponential', + initialDelay: 1000, + maxDelay: 30000, + }, + createdBy: mockUserId, + createdAt: new Date(), + }; + + beforeEach(async () => { + const mockWebhookRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + const mockDeliveryRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + const mockWebhookQueue = { + add: jest.fn(), + getJob: jest.fn(), + remove: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WebhookService, + { + provide: getRepositoryToken(WebhookEntity), + useValue: mockWebhookRepo, + }, + { + provide: getRepositoryToken(WebhookDeliveryEntity), + useValue: mockDeliveryRepo, + }, + { + provide: getQueueToken('webhook-queue'), + useValue: mockWebhookQueue, + }, + ], + }).compile(); + + service = module.get(WebhookService); + webhookRepo = module.get(getRepositoryToken(WebhookEntity)); + deliveryRepo = module.get(getRepositoryToken(WebhookDeliveryEntity)); + webhookQueue = module.get(getQueueToken('webhook-queue')); + }); + + describe('retryFailedDelivery', () => { + it('should retry delivery with exponential backoff', async () => { + const deliveryId = 'delivery-001'; + const mockDelivery = { + id: deliveryId, + webhookId: 'webhook-001', + tenantId: mockTenantId, + eventType: 'user.created', + payload: { type: 'user.created', data: { id: 'user-001' } }, + status: DeliveryStatus.FAILED, + attempt: 1, + maxAttempts: 3, + lastError: 'Connection timeout', + nextRetryAt: new Date(Date.now() + 2000), + createdAt: new Date(), + }; + + const expectedRetryDelivery = { + ...mockDelivery, + attempt: 2, + lastError: null, + nextRetryAt: new Date(Date.now() + 4000), // Exponential: 2^1 * 1000ms + }; + + jest.spyOn(deliveryRepo, 'findOne').mockResolvedValue(mockDelivery as any); + jest.spyOn(deliveryRepo, 'save').mockResolvedValue(expectedRetryDelivery as any); + jest.spyOn(webhookQueue, 'add').mockResolvedValue({ id: 'job-123' } as any); + + const result = await service.retryFailedDelivery(deliveryId); + + expect(deliveryRepo.findOne).toHaveBeenCalledWith({ + where: { id: deliveryId }, + }); + + // Check exponential backoff calculation + const delay = Math.min( + 1000 * Math.pow(2, mockDelivery.attempt), + 30000, + ); + expect(expectedRetryDelivery.nextRetryAt.getTime()).toBeCloseTo( + Date.now() + delay, + 100, + ); + + expect(deliveryRepo.save).toHaveBeenCalledWith(expectedRetryDelivery); + expect(webhookQueue.add).toHaveBeenCalledWith( + 'webhook-delivery', + { + deliveryId: deliveryId, + webhookId: mockDelivery.webhookId, + url: expect.any(String), + payload: mockDelivery.payload, + headers: expect.any(Object), + attempt: 2, + }, + { + delay: Math.floor(delay / 1000), + attempts: 3, + backoff: { + type: 'exponential', + delay: delay, + }, + }, + ); + + expect(result).toEqual(expectedRetryDelivery); + }); + + it('should not retry if max attempts reached', async () => { + const deliveryId = 'delivery-002'; + const mockDelivery = { + id: deliveryId, + webhookId: 'webhook-001', + tenantId: mockTenantId, + eventType: 'user.created', + payload: { type: 'user.created', data: { id: 'user-001' } }, + status: DeliveryStatus.FAILED, + attempt: 3, + maxAttempts: 3, + lastError: 'Connection timeout', + createdAt: new Date(), + }; + + const expectedFinalDelivery = { + ...mockDelivery, + status: DeliveryStatus.FAILED_PERMANENT, + failedAt: expect.any(Date), + finalError: 'Max retry attempts exceeded', + }; + + jest.spyOn(deliveryRepo, 'findOne').mockResolvedValue(mockDelivery as any); + jest.spyOn(deliveryRepo, 'save').mockResolvedValue(expectedFinalDelivery as any); + + const result = await service.retryFailedDelivery(deliveryId); + + expect(deliveryRepo.save).toHaveBeenCalledWith(expectedFinalDelivery); + expect(webhookQueue.add).not.toHaveBeenCalled(); + expect(result).toEqual(expectedFinalDelivery); + }); + + it('should not retry if webhook is inactive', async () => { + const deliveryId = 'delivery-003'; + const mockDelivery = { + id: deliveryId, + webhookId: 'webhook-001', + tenantId: mockTenantId, + status: DeliveryStatus.FAILED, + attempt: 1, + maxAttempts: 3, + }; + + const inactiveWebhook = { + ...mockWebhook, + isActive: false, + }; + + jest.spyOn(deliveryRepo, 'findOne').mockResolvedValue(mockDelivery as any); + jest.spyOn(webhookRepo, 'findOne').mockResolvedValue(inactiveWebhook as any); + + await expect(service.retryFailedDelivery(deliveryId)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('calculateBackoffDelay', () => { + it('should calculate exponential backoff correctly', () => { + const retryPolicy = { + maxAttempts: 3, + backoffStrategy: 'exponential', + initialDelay: 1000, + maxDelay: 30000, + }; + + // Test exponential backoff + expect(service['calculateBackoffDelay'](retryPolicy, 1)).toBe(1000); // 2^0 * 1000 + expect(service['calculateBackoffDelay'](retryPolicy, 2)).toBe(2000); // 2^1 * 1000 + expect(service['calculateBackoffDelay'](retryPolicy, 3)).toBe(4000); // 2^2 * 1000 + }); + + it('should respect max delay limit', () => { + const retryPolicy = { + maxAttempts: 10, + backoffStrategy: 'exponential', + initialDelay: 1000, + maxDelay: 5000, + }; + + // Should not exceed maxDelay + expect(service['calculateBackoffDelay'](retryPolicy, 6)).toBe(5000); // 2^5 * 1000 = 32000 > 5000 + expect(service['calculateBackoffDelay'](retryPolicy, 10)).toBe(5000); + }); + + it('should handle linear backoff', () => { + const retryPolicy = { + maxAttempts: 3, + backoffStrategy: 'linear', + initialDelay: 1000, + maxDelay: 30000, + }; + + expect(service['calculateBackoffDelay'](retryPolicy, 1)).toBe(1000); + expect(service['calculateBackoffDelay'](retryPolicy, 2)).toBe(2000); + expect(service['calculateBackoffDelay'](retryPolicy, 3)).toBe(3000); + }); + + it('should handle fixed delay', () => { + const retryPolicy = { + maxAttempts: 3, + backoffStrategy: 'fixed', + initialDelay: 5000, + maxDelay: 30000, + }; + + expect(service['calculateBackoffDelay'](retryPolicy, 1)).toBe(5000); + expect(service['calculateBackoffDelay'](retryPolicy, 2)).toBe(5000); + expect(service['calculateBackoffDelay'](retryPolicy, 3)).toBe(5000); + }); + }); + + describe('processDeliveryQueue', () => { + it('should process successful delivery', async () => { + const job = { + data: { + deliveryId: 'delivery-004', + webhookId: 'webhook-001', + url: 'https://example.com/webhook', + payload: { test: 'data' }, + headers: { 'Authorization': 'Bearer token' }, + attempt: 1, + }, + opts: { + attempts: 3, + }, + id: 'job-123', + }; + + const mockResponse = { + status: 200, + data: { success: true }, + }; + + const updatedDelivery = { + id: job.data.deliveryId, + status: DeliveryStatus.DELIVERED, + attempt: job.data.attempt, + deliveredAt: new Date(), + responseStatus: 200, + responseBody: JSON.stringify(mockResponse), + }; + + jest.spyOn(service, 'sendHttpRequest').mockResolvedValue(mockResponse); + jest.spyOn(deliveryRepo, 'findOne').mockResolvedValue({ + id: job.data.deliveryId, + } as any); + jest.spyOn(deliveryRepo, 'save').mockResolvedValue(updatedDelivery as any); + + const result = await service.processDeliveryQueue(job); + + expect(service.sendHttpRequest).toHaveBeenCalledWith( + job.data.url, + job.data.payload, + job.data.headers, + ); + expect(deliveryRepo.save).toHaveBeenCalledWith(updatedDelivery); + expect(result).toEqual(updatedDelivery); + }); + + it('should handle delivery failure and schedule retry', async () => { + const job = { + data: { + deliveryId: 'delivery-005', + webhookId: 'webhook-001', + url: 'https://example.com/webhook', + payload: { test: 'data' }, + attempt: 1, + }, + opts: { + attempts: 3, + }, + id: 'job-123', + }; + + const error = new Error('Connection timeout'); + error.code = 'ECONNRESET'; + + const failedDelivery = { + id: job.data.deliveryId, + status: DeliveryStatus.FAILED, + attempt: job.data.attempt, + lastError: error.message, + nextRetryAt: new Date(Date.now() + 2000), + }; + + jest.spyOn(service, 'sendHttpRequest').mockRejectedValue(error); + jest.spyOn(deliveryRepo, 'findOne').mockResolvedValue({ + id: job.data.deliveryId, + } as any); + jest.spyOn(deliveryRepo, 'save').mockResolvedValue(failedDelivery as any); + jest.spyOn(service, 'retryFailedDelivery').mockResolvedValue(failedDelivery as any); + + const result = await service.processDeliveryQueue(job); + + expect(service.sendHttpRequest).toHaveBeenCalled(); + expect(deliveryRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: DeliveryStatus.FAILED, + lastError: error.message, + }), + ); + expect(service.retryFailedDelivery).toHaveBeenCalledWith(job.data.deliveryId); + expect(result).toEqual(failedDelivery); + }); + + it('should handle 429 rate limit with longer backoff', async () => { + const job = { + data: { + deliveryId: 'delivery-006', + webhookId: 'webhook-001', + url: 'https://example.com/webhook', + payload: { test: 'data' }, + attempt: 1, + }, + opts: { + attempts: 3, + }, + }; + + const rateLimitError = new Error('Too Many Requests'); + rateLimitError.code = '429'; + + const rateLimitedDelivery = { + id: job.data.deliveryId, + status: DeliveryStatus.FAILED, + attempt: job.data.attempt, + lastError: rateLimitError.message, + nextRetryAt: new Date(Date.now() + 60000), // Longer delay for rate limit + }; + + jest.spyOn(service, 'sendHttpRequest').mockRejectedValue(rateLimitError); + jest.spyOn(deliveryRepo, 'findOne').mockResolvedValue({ + id: job.data.deliveryId, + } as any); + jest.spyOn(deliveryRepo, 'save').mockResolvedValue(rateLimitedDelivery as any); + + const result = await service.processDeliveryQueue(job); + + expect(deliveryRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + lastError: rateLimitError.message, + nextRetryAt: expect.any(Date), + }), + ); + + // Verify rate limit gets special treatment + const nextRetryDelay = rateLimitedDelivery.nextRetryAt.getTime() - Date.now(); + expect(nextRetryDelay).toBeGreaterThan(30000); // Should exceed normal max delay + }); + }); + + describe('getRetryStatistics', () => { + it('should return retry statistics for webhook', async () => { + const webhookId = 'webhook-001'; + const mockStats = { + totalDeliveries: 100, + successfulDeliveries: 85, + failedDeliveries: 15, + averageAttempts: 1.2, + retryRate: 0.15, + last24Hours: { + total: 20, + successful: 18, + failed: 2, + }, + }; + + jest.spyOn(deliveryRepo, 'count').mockResolvedValue(mockStats.totalDeliveries); + jest.spyOn(deliveryRepo, 'count').mockResolvedValue(mockStats.successfulDeliveries); + jest.spyOn(deliveryRepo, 'average').mockResolvedValue(mockStats.averageAttempts); + + const result = await service.getRetryStatistics(webhookId); + + expect(result).toEqual(mockStats); + }); + }); +});