diff --git a/src/notifications/channels/channel.interface.ts b/src/notifications/channels/channel.interface.ts new file mode 100644 index 0000000..190ccf1 --- /dev/null +++ b/src/notifications/channels/channel.interface.ts @@ -0,0 +1,29 @@ +import { Notification } from '../entities/notification.entity'; + +export interface ChannelDeliveryResult { + success: boolean; + error?: string; + deliveryTimestamp?: Date; +} + +export interface NotificationChannel { + /** + * Unique identifier for the channel + */ + readonly channelType: string; + + /** + * Check if this channel is enabled for a specific user + */ + isEnabled(userId: string): Promise; + + /** + * Send a notification through this channel + */ + send(notification: Notification): Promise; + + /** + * Validate user configuration for this channel + */ + validateConfig(userId: string): Promise<{ valid: boolean; errors: string[] }>; +} \ No newline at end of file diff --git a/src/notifications/channels/email.channel.ts b/src/notifications/channels/email.channel.ts new file mode 100644 index 0000000..e1706d5 --- /dev/null +++ b/src/notifications/channels/email.channel.ts @@ -0,0 +1,105 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationChannel as ChannelType, Notification } from '../entities/notification.entity'; +import { UserNotificationPreferences } from '../entities/notification.entity'; +import { NotificationChannel, ChannelDeliveryResult } from './channel.interface'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class EmailChannel implements NotificationChannel { + private readonly logger = new Logger(EmailChannel.name); + readonly channelType = ChannelType.EMAIL; + + constructor( + @InjectRepository(UserNotificationPreferences) + private readonly preferencesRepository: Repository, + private readonly configService: ConfigService, + ) {} + + async isEnabled(userId: string): Promise { + const preferences = await this.preferencesRepository.findOne({ + where: { userId }, + }); + + if (!preferences) { + return false; // Default to disabled if no email configured + } + + return preferences.enabledChannels[this.channelType] ?? false && preferences.emailEnabled; + } + + async send(notification: Notification): Promise { + const preferences = await this.preferencesRepository.findOne({ + where: { userId: notification.recipientId }, + }); + + if (!preferences || !preferences.emailAddress) { + return { + success: false, + error: 'No email address configured for user', + }; + } + + this.logger.debug( + `Sending email notification ${notification.id} to ${preferences.emailAddress}`, + ); + + // In a real implementation, this would integrate with an email service like SendGrid, AWS SES, etc. + // For now, we'll just log that the email would be sent + + const emailHtml = this.generateEmailHtml(notification); + this.logger.debug(`Email content would be: ${emailHtml.substring(0, 200)}...`); + + return { + success: true, + deliveryTimestamp: new Date(), + }; + } + + async validateConfig(userId: string): Promise<{ valid: boolean; errors: string[] }> { + const errors: string[] = []; + const preferences = await this.preferencesRepository.findOne({ + where: { userId }, + }); + + if (!preferences) { + errors.push('No user preferences found'); + return { valid: false, errors }; + } + + if (!preferences.emailAddress) { + errors.push('No email address configured'); + } + + if (!preferences.emailEnabled) { + errors.push('Email notifications are disabled'); + } + + const smtpHost = this.configService.get('SMTP_HOST'); + if (!smtpHost) { + errors.push('SMTP server not configured'); + } + + return { + valid: errors.length === 0, + errors, + }; + } + + private generateEmailHtml(notification: Notification): string { + return ` + + + + ${notification.title} + + +

${notification.title}

+

${notification.message}

+ ${notification.metadata ? `
${JSON.stringify(notification.metadata, null, 2)}
` : ''} + + + `; + } +} \ No newline at end of file diff --git a/src/notifications/channels/in-app.channel.ts b/src/notifications/channels/in-app.channel.ts new file mode 100644 index 0000000..db5789f --- /dev/null +++ b/src/notifications/channels/in-app.channel.ts @@ -0,0 +1,49 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationChannel as ChannelType, Notification } from '../entities/notification.entity'; +import { UserNotificationPreferences } from '../entities/notification.entity'; +import { NotificationChannel, ChannelDeliveryResult } from './channel.interface'; + +@Injectable() +export class InAppChannel implements NotificationChannel { + private readonly logger = new Logger(InAppChannel.name); + readonly channelType = ChannelType.IN_APP; + + constructor( + @InjectRepository(UserNotificationPreferences) + private readonly preferencesRepository: Repository, + ) {} + + async isEnabled(userId: string): Promise { + const preferences = await this.preferencesRepository.findOne({ + where: { userId }, + }); + + if (!preferences) { + return true; // Default to enabled if no preferences set + } + + return preferences.enabledChannels[this.channelType] ?? true; + } + + async send(notification: Notification): Promise { + this.logger.debug( + `Sending in-app notification ${notification.id} to user ${notification.recipientId}`, + ); + + // In-app notifications are just stored in the database, they're retrieved via API + // The WebSocket server will broadcast the new notification to connected clients + + return { + success: true, + deliveryTimestamp: new Date(), + }; + } + + async validateConfig(userId: string): Promise<{ valid: boolean; errors: string[] }> { + const errors: string[] = []; + // In-app channel always has a valid config since it doesn't require any user configuration + return { valid: true, errors }; + } +} \ No newline at end of file diff --git a/src/notifications/channels/push.channel.ts b/src/notifications/channels/push.channel.ts new file mode 100644 index 0000000..0ef198b --- /dev/null +++ b/src/notifications/channels/push.channel.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationChannel as ChannelType, Notification } from '../entities/notification.entity'; +import { UserNotificationPreferences } from '../entities/notification.entity'; +import { NotificationChannel, ChannelDeliveryResult } from './channel.interface'; + +@Injectable() +export class PushChannel implements NotificationChannel { + private readonly logger = new Logger(PushChannel.name); + readonly channelType = ChannelType.PUSH; + + constructor( + @InjectRepository(UserNotificationPreferences) + private readonly preferencesRepository: Repository, + ) {} + + async isEnabled(userId: string): Promise { + const preferences = await this.preferencesRepository.findOne({ + where: { userId }, + }); + + if (!preferences || !preferences.pushSubscription) { + return false; + } + + return preferences.enabledChannels[this.channelType] ?? false; + } + + async send(notification: Notification): Promise { + const preferences = await this.preferencesRepository.findOne({ + where: { userId: notification.recipientId }, + }); + + if (!preferences?.pushSubscription?.endpoint) { + return { + success: false, + error: 'No push subscription configured for user', + }; + } + + this.logger.debug( + `Sending push notification ${notification.id} to user ${notification.recipientId}`, + ); + + // In a real implementation, this would use a service like Firebase Cloud Messaging (FCM), + // Apple Push Notification Service (APNs), or a web push library to send the notification + // to the user's device + + const pushPayload = { + title: notification.title, + body: notification.message, + data: { + notificationId: notification.id, + type: notification.type, + ...notification.metadata, + }, + }; + + this.logger.debug(`Push payload: ${JSON.stringify(pushPayload)}`); + + return { + success: true, + deliveryTimestamp: new Date(), + }; + } + + async validateConfig(userId: string): Promise<{ valid: boolean; errors: string[] }> { + const errors: string[] = []; + const preferences = await this.preferencesRepository.findOne({ + where: { userId }, + }); + + if (!preferences) { + errors.push('No user preferences found'); + return { valid: false, errors }; + } + + if (!preferences.pushSubscription) { + errors.push('No push subscription configured'); + return { valid: false, errors }; + } + + if (!preferences.pushSubscription.endpoint) { + errors.push('Push subscription endpoint not provided'); + } + + if (!preferences.pushSubscription.keys?.p256dh || !preferences.pushSubscription.keys?.auth) { + errors.push('Push subscription encryption keys missing'); + } + + return { + valid: errors.length === 0, + errors, + }; + } +} \ No newline at end of file diff --git a/src/notifications/channels/webhook.channel.ts b/src/notifications/channels/webhook.channel.ts new file mode 100644 index 0000000..b0e45ae --- /dev/null +++ b/src/notifications/channels/webhook.channel.ts @@ -0,0 +1,125 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationChannel as ChannelType, Notification } from '../entities/notification.entity'; +import { UserNotificationPreferences } from '../entities/notification.entity'; +import { NotificationChannel, ChannelDeliveryResult } from './channel.interface'; +import { createHmac } from 'crypto'; + +@Injectable() +export class WebhookChannel implements NotificationChannel { + private readonly logger = new Logger(WebhookChannel.name); + readonly channelType = ChannelType.WEBHOOK; + + constructor( + @InjectRepository(UserNotificationPreferences) + private readonly preferencesRepository: Repository, + ) {} + + async isEnabled(userId: string): Promise { + const preferences = await this.preferencesRepository.findOne({ + where: { userId }, + }); + + if (!preferences || !preferences.webhookConfig) { + return false; + } + + return preferences.enabledChannels[this.channelType] ?? false && preferences.webhookConfig.enabled; + } + + async send(notification: Notification): Promise { + const preferences = await this.preferencesRepository.findOne({ + where: { userId: notification.recipientId }, + }); + + if (!preferences?.webhookConfig?.url) { + return { + success: false, + error: 'Webhook URL not configured', + }; + } + + const { url, secret } = preferences.webhookConfig; + this.logger.debug( + `Sending webhook notification ${notification.id} to ${url}`, + ); + + try { + // Create payload + const payload = JSON.stringify({ + notificationId: notification.id, + type: notification.type, + title: notification.title, + message: notification.message, + metadata: notification.metadata, + timestamp: new Date().toISOString(), + }); + + // Generate signature for verification + const signature = this.generateSignature(payload, secret); + + // In a real implementation, this would make an HTTP POST request to the webhook URL + // fetch(url, { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // 'X-Signature': signature, + // }, + // body: payload, + // }); + + this.logger.debug(`Webhook payload would be sent to ${url} with signature ${signature}`); + + return { + success: true, + deliveryTimestamp: new Date(), + }; + } catch (error) { + return { + success: false, + error: `Failed to deliver webhook: ${error.message}`, + }; + } + } + + async validateConfig(userId: string): Promise<{ valid: boolean; errors: string[] }> { + const errors: string[] = []; + const preferences = await this.preferencesRepository.findOne({ + where: { userId }, + }); + + if (!preferences) { + errors.push('No user preferences found'); + return { valid: false, errors }; + } + + if (!preferences.webhookConfig) { + errors.push('Webhook not configured'); + return { valid: false, errors }; + } + + if (!preferences.webhookConfig.url) { + errors.push('Webhook URL not provided'); + } + + if (!preferences.webhookConfig.secret) { + errors.push('Webhook secret not provided'); + } + + if (!preferences.webhookConfig.enabled) { + errors.push('Webhook is disabled'); + } + + return { + valid: errors.length === 0, + errors, + }; + } + + private generateSignature(payload: string, secret: string): string { + return createHmac('sha256', secret) + .update(payload) + .digest('hex'); + } +} \ No newline at end of file diff --git a/src/notifications/channels/websocket.channel.ts b/src/notifications/channels/websocket.channel.ts new file mode 100644 index 0000000..c208b74 --- /dev/null +++ b/src/notifications/channels/websocket.channel.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationChannel as ChannelType, Notification } from '../entities/notification.entity'; +import { UserNotificationPreferences } from '../entities/notification.entity'; +import { NotificationChannel, ChannelDeliveryResult } from './channel.interface'; +import { NotificationGateway } from '../websockets/websocket.gateway'; + +@Injectable() +export class WebSocketChannel implements NotificationChannel { + private readonly logger = new Logger(WebSocketChannel.name); + readonly channelType = ChannelType.WEBSOCKET; + + constructor( + @InjectRepository(UserNotificationPreferences) + private readonly preferencesRepository: Repository, + private readonly gateway: NotificationGateway, + ) {} + + async isEnabled(userId: string): Promise { + const preferences = await this.preferencesRepository.findOne({ + where: { userId }, + }); + + if (!preferences) { + return true; // Default to enabled if no preferences set + } + + return preferences.enabledChannels[this.channelType] ?? true; + } + + async send(notification: Notification): Promise { + this.logger.debug( + `Sending WebSocket notification ${notification.id} to user ${notification.recipientId}`, + ); + + const delivered = this.gateway.sendToUser(notification.recipientId, notification); + + if (delivered) { + return { + success: true, + deliveryTimestamp: new Date(), + }; + } else { + return { + success: false, + error: 'User not connected to WebSocket server', + }; + } + } + + async validateConfig(userId: string): Promise<{ valid: boolean; errors: string[] }> { + const errors: string[] = []; + // WebSocket doesn't require any special configuration, just needs an active connection + const isOnline = this.gateway.isUserOnline(userId); + if (!isOnline) { + errors.push('User is not currently connected to WebSocket server'); + } + return { valid: isOnline, errors }; + } +} \ No newline at end of file diff --git a/src/notifications/controllers/internal-notification.controller.ts b/src/notifications/controllers/internal-notification.controller.ts new file mode 100644 index 0000000..2163924 --- /dev/null +++ b/src/notifications/controllers/internal-notification.controller.ts @@ -0,0 +1,32 @@ +import { + Controller, + Post, + Body, + UseGuards, + HttpStatus, + HttpCode, +} from '@nestjs/common'; +import { ServiceAuthGuard } from '../../auth/guards/service-auth.guard'; +import { NotificationsService } from '../services/notifications.service'; +import { NotificationEvent } from '../interfaces/notification.types'; + +@Controller('internal/notifications') +@UseGuards(ServiceAuthGuard) +export class InternalNotificationController { + constructor(private readonly notificationsService: NotificationsService) {} + + /** + * Process a protocol event from another service and convert it to notifications + * This endpoint is only accessible to internal services with valid service tokens + */ + @Post('process-event') + @HttpCode(HttpStatus.ACCEPTED) + async processProtocolEvent(@Body() event: NotificationEvent) { + await this.notificationsService.processIncomingEvent(event); + + return { + success: true, + message: 'Event processing queued', + }; + } +} \ No newline at end of file diff --git a/src/notifications/dto/notification.dto.ts b/src/notifications/dto/notification.dto.ts new file mode 100644 index 0000000..0753f49 --- /dev/null +++ b/src/notifications/dto/notification.dto.ts @@ -0,0 +1,128 @@ +import { + IsString, + IsBoolean, + IsOptional, + IsEnum, + IsJSON, + IsEmail, + IsUrl, + IsInt, + Min, + Max, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + NotificationType, + NotificationChannel, +} from '../entities/notification.entity'; + +export class CreateNotificationDto { + @IsString() + recipientId: string; + + @IsEnum(NotificationType) + type: NotificationType; + + @IsString() + title: string; + + @IsString() + message: string; + + @IsOptional() + metadata?: Record; +} + +export class UpdatePreferencesDto { + @IsOptional() + enabledChannels?: Record; + + @IsOptional() + enabledCategories?: Record; + + @IsOptional() + @IsBoolean() + emailEnabled?: boolean; + + @IsOptional() + @IsEmail() + emailAddress?: string; + + @IsOptional() + @IsBoolean() + governanceAlerts?: boolean; + + @IsOptional() + @IsBoolean() + stakingAlerts?: boolean; + + @IsOptional() + @IsBoolean() + rewardNotifications?: boolean; + + @IsOptional() + @IsBoolean() + securityAlerts?: boolean; + + @IsOptional() + webhookConfig?: { + url: string; + secret: string; + enabled: boolean; + }; + + @IsOptional() + pushSubscription?: { + endpoint: string; + keys: Record; + }; +} + +export class NotificationQueryDto { + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + page?: number = 1; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + @Type(() => Number) + limit?: number = 20; + + @IsOptional() + @IsEnum(NotificationType) + type?: NotificationType; + + @IsOptional() + @IsBoolean() + read?: boolean; + + @IsOptional() + @IsString() + startDate?: string; + + @IsOptional() + @IsString() + endDate?: string; +} + +export class MarkAsReadDto { + @IsString() + notificationId: string; +} + +export class ProtocolEventDto { + @IsString() + source: string; // blockchain-indexer, governance-service, etc. + @IsString() + eventType: string; + @IsString() + recipientId: string; + @IsJSON() + payload: Record; + @IsOptional() + timestamp?: Date; +} \ No newline at end of file diff --git a/src/notifications/entities/notification.entity.ts b/src/notifications/entities/notification.entity.ts index 3d94325..63b4b54 100644 --- a/src/notifications/entities/notification.entity.ts +++ b/src/notifications/entities/notification.entity.ts @@ -1,8 +1,3 @@ -import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm'; -import { User } from '../../entities/user.entity'; -import { NotificationCategory, NotificationPriority } from '../interfaces/notification.types'; - -@Entity('notifications') import { Entity, PrimaryGeneratedColumn, @@ -10,109 +5,165 @@ import { CreateDateColumn, UpdateDateColumn, Index, - ManyToOne, - OneToMany, - JoinColumn, } from 'typeorm'; -import { User } from '../../entities/user.entity'; -import { - NotificationType, - DeliveryChannel, - NotificationPriority, -} from '../enums/notification-type.enum'; -import { NotificationDelivery } from './notification-delivery.entity'; + +export enum NotificationType { + NEW_CLAIM = 'new_claim', + VERIFICATION_ASSIGNMENT = 'verification_assignment', + DISPUTE_CREATED = 'dispute_created', + DISPUTE_RESOLVED = 'dispute_resolved', + REPUTATION_UPDATE = 'reputation_update', + STAKING_CHANGE = 'staking_change', + GOVERNANCE_PROPOSAL = 'governance_proposal', + PROPOSAL_VOTE = 'proposal_vote', + REWARD_DISTRIBUTED = 'reward_distributed', + MODERATION_ACTION = 'moderation_action', + SECURITY_ALERT = 'security_alert', +} + +export enum NotificationChannel { + IN_APP = 'in_app', + WEBSOCKET = 'websocket', + PUSH = 'push', + EMAIL = 'email', + WEBHOOK = 'webhook', +} + +export enum DeliveryStatus { + PENDING = 'pending', + DELIVERED = 'delivered', + FAILED = 'failed', + RETRYING = 'retrying', + DEAD_LETTER = 'dead_letter', +} @Entity('notifications') -@Index(['userId', 'createdAt']) -@Index(['userId', 'readAt']) -@Index(['type']) -@Index(['status']) export class Notification { @PrimaryGeneratedColumn('uuid') id: string; @Column() - userId: string; + @Index() + recipientId: string; - @ManyToOne(() => User, { onDelete: 'CASCADE' }) - user: User; + @Column({ + type: 'varchar', + length: 50, + }) + type: NotificationType; - @Column() + @Column('text') title: string; @Column('text') message: string; + @Column('jsonb', { nullable: true }) + metadata: Record; + + @Column({ default: false }) + read: boolean; + + @Column({ nullable: true }) + readAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} + +@Entity('notification_delivery_history') +export class NotificationDeliveryHistory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + @Index() + notificationId: string; + + @Column() + @Index() + recipientId: string; + @Column({ type: 'varchar', - length: 50, + length: 30, }) - category: NotificationCategory; + channel: NotificationChannel; @Column({ type: 'varchar', - length: 20, - default: 'medium', + length: 30, }) - priority: NotificationPriority; - - @Column({ type: 'jsonb', nullable: true }) - metadata: Record; + status: DeliveryStatus; - @Column({ type: 'jsonb', nullable: true }) - sourceEvent: Record; + @Column({ nullable: true }) + deliveredAt: Date; - @Column({ default: false }) - read: boolean; + @Column({ default: 0 }) + retryAttempts: number; - @Column({ nullable: true }) - readAt: Date; + @Column('text', { nullable: true }) + failureReason: string; - @Column({ nullable: true }) - scheduledFor: Date; - @JoinColumn({ name: 'userId' }) - user: User; + @CreateDateColumn() + createdAt: Date; - @Column({ nullable: true }) - walletAddress: string; + @UpdateDateColumn() + updatedAt: Date; +} - @Column({ type: 'varchar', enum: NotificationType }) - type: NotificationType; +@Entity('user_notification_preferences') +@Index(['userId'], { unique: true }) +export class UserNotificationPreferences { + @PrimaryGeneratedColumn('uuid') + id: string; @Column() - title: string; + userId: string; - @Column({ type: 'text' }) - body: string; + @Column('jsonb') + enabledChannels: Record; - @Column({ type: 'json', nullable: true }) - data: Record; + @Column('jsonb') + enabledCategories: Record; - @Column({ type: 'varchar', enum: NotificationPriority, default: NotificationPriority.NORMAL }) - priority: NotificationPriority; + @Column({ default: true }) + emailEnabled: boolean; - @Column({ default: false }) - read: boolean; + @Column({ nullable: true }) + emailAddress: string; - @Column({ type: 'datetime', nullable: true }) - readAt: Date; + @Column({ default: true }) + governanceAlerts: boolean; + + @Column({ default: true }) + stakingAlerts: boolean; - @Column({ type: 'varchar', default: 'PENDING' }) - status: string; + @Column({ default: true }) + rewardNotifications: boolean; - @Column({ type: 'datetime', nullable: true }) - scheduledAt: Date; + @Column({ default: true }) + securityAlerts: boolean; - @Column({ type: 'datetime', nullable: true }) - sentAt: Date; + @Column('jsonb', { nullable: true }) + webhookConfig: { + url: string; + secret: string; + enabled: boolean; + }; - @OneToMany(() => NotificationDelivery, (delivery) => delivery.notification, { cascade: true }) - deliveries: NotificationDelivery[]; + @Column('jsonb', { nullable: true }) + pushSubscription: { + endpoint: string; + keys: Record; + }; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; -} -} +} \ No newline at end of file diff --git a/src/notifications/metrics/notification.metrics.ts b/src/notifications/metrics/notification.metrics.ts new file mode 100644 index 0000000..5840e95 --- /dev/null +++ b/src/notifications/metrics/notification.metrics.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@nestjs/common'; +import { Counter, Gauge, Histogram, register } from 'prom-client'; + +@Injectable() +export class NotificationMetricsService { + // Counters + private notificationsCreated: Counter; + private notificationsSent: Counter; + private notificationsFailed: Counter; + private notificationsRetried: Counter; + + // Gauges + private queueDepth: Gauge; + private connectedUsers: Gauge; + + // Histograms + private processingLatency: Histogram; + private deliveryLatency: Histogram; + + constructor() { + this.initializeMetrics(); + } + + private initializeMetrics() { + // Notifications created counter + this.notificationsCreated = new Counter({ + name: 'notifications_created_total', + help: 'Total number of notifications created', + labelNames: ['type'], + }); + + // Notifications sent counter + this.notificationsSent = new Counter({ + name: 'notifications_sent_total', + help: 'Total number of notifications successfully delivered', + labelNames: ['channel', 'type'], + }); + + // Notifications failed counter + this.notificationsFailed = new Counter({ + name: 'notifications_failed_total', + help: 'Total number of notifications that failed delivery', + labelNames: ['channel', 'type', 'reason'], + }); + + // Notifications retried counter + this.notificationsRetried = new Counter({ + name: 'notifications_retried_total', + help: 'Total number of notification delivery retries', + labelNames: ['channel'], + }); + + // Queue depth gauge + this.queueDepth = new Gauge({ + name: 'notifications_queue_depth', + help: 'Current number of notifications waiting in the queue', + labelNames: ['queue'], + }); + + // Connected users gauge + this.connectedUsers = new Gauge({ + name: 'notifications_connected_users', + help: 'Number of users currently connected via WebSocket', + }); + + // Processing latency histogram + this.processingLatency = new Histogram({ + name: 'notifications_processing_latency_seconds', + help: 'Time taken to process and queue a notification', + buckets: [0.01, 0.05, 0.1, 0.5, 1, 5], + }); + + // Delivery latency histogram + this.deliveryLatency = new Histogram({ + name: 'notifications_delivery_latency_seconds', + help: 'Time taken to deliver a notification after queuing', + buckets: [0.1, 0.5, 1, 5, 10, 30], + }); + + // Register all metrics + register.registerMetric(this.notificationsCreated); + register.registerMetric(this.notificationsSent); + register.registerMetric(this.notificationsFailed); + register.registerMetric(this.notificationsRetried); + register.registerMetric(this.queueDepth); + register.registerMetric(this.connectedUsers); + register.registerMetric(this.processingLatency); + register.registerMetric(this.deliveryLatency); + } + + // Increment notifications created + incrementCreated(type: string) { + this.notificationsCreated.inc({ type }); + } + + // Increment notifications sent + incrementSent(channel: string, type: string) { + this.notificationsSent.inc({ channel, type }); + } + + // Increment notifications failed + incrementFailed(channel: string, type: string, reason: string) { + this.notificationsFailed.inc({ channel, type, reason }); + } + + // Increment retries + incrementRetried(channel: string) { + this.notificationsRetried.inc({ channel }); + } + + // Update queue depth + setQueueDepth(queue: string, depth: number) { + this.queueDepth.set({ queue }, depth); + } + + // Update connected users count + setConnectedUsers(count: number) { + this.connectedUsers.set(count); + } + + // Observe processing latency + observeProcessingLatency(duration: number) { + this.processingLatency.observe(duration); + } + + // Observe delivery latency + observeDeliveryLatency(duration: number) { + this.deliveryLatency.observe(duration); + } + + // Get all metrics for Prometheus scraping + async getMetrics() { + return register.metrics(); + } +} \ No newline at end of file diff --git a/src/notifications/notification.module.ts b/src/notifications/notification.module.ts deleted file mode 100644 index 6ec881d..0000000 --- a/src/notifications/notification.module.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { BullModule } from '@nestjs/bullmq'; -import { BullBoardModule } from '@bull-board/nestjs'; -import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; -import { Notification } from './entities/notification.entity'; -import { NotificationDelivery } from './entities/notification-delivery.entity'; -import { UserNotificationPreference } from './entities/user-notification-preference.entity'; -import { NotificationTemplate } from './entities/notification-template.entity'; -import { NotificationController } from './notification.controller'; -import { NotificationService } from './services/notification.service'; -import { NotificationProcessor } from './notification.processor'; -import { TemplateService } from './services/template.service'; -import { InAppDeliveryService } from './services/delivery/in-app-delivery.service'; -import { EmailDeliveryService } from './services/delivery/email-delivery.service'; -import { WebhookDeliveryService } from './services/delivery/webhook-delivery.service'; -import { PushDeliveryService } from './services/delivery/push-delivery.service'; -import { SmsDeliveryService } from './services/delivery/sms-delivery.service'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([ - Notification, - NotificationDelivery, - UserNotificationPreference, - NotificationTemplate, - ]), - BullModule.registerQueue({ - name: 'notifications-queue', - }), - BullBoardModule.forFeature({ - name: 'notifications-queue', - adapter: BullMQAdapter, - }), - ], - controllers: [NotificationController], - providers: [ - NotificationService, - NotificationProcessor, - TemplateService, - InAppDeliveryService, - EmailDeliveryService, - WebhookDeliveryService, - PushDeliveryService, - SmsDeliveryService, - ], - exports: [ - NotificationService, - TemplateService, - BullModule, - ], -}) -export class NotificationModule {} diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts index fa6062e..4e3f138 100644 --- a/src/notifications/notifications.module.ts +++ b/src/notifications/notifications.module.ts @@ -5,6 +5,7 @@ import { PrismaModule } from '../prisma/prisma.module'; import { RedisModule } from '../redis/redis.module'; import { LoggerModule } from '../logger/logger.module'; import { NotificationsController } from './controllers/notifications.controller'; +import { InternalNotificationController } from './controllers/internal-notification.controller'; import { NotificationsService } from './services/notifications.service'; import { NotificationPreferencesService } from './services/notification-preferences.service'; import { DeliveryHistoryService } from './services/delivery-history.service'; @@ -38,7 +39,7 @@ import { AuthModule } from '../auth/auth.module'; MetricsModule, AuthModule, ], - controllers: [NotificationsController], + controllers: [NotificationsController, InternalNotificationController], providers: [ NotificationsService, NotificationPreferencesService, diff --git a/src/notifications/processors/notification.processor.ts b/src/notifications/processors/notification.processor.ts new file mode 100644 index 0000000..3274538 --- /dev/null +++ b/src/notifications/processors/notification.processor.ts @@ -0,0 +1,153 @@ +import { Process, Processor } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Logger } from '@nestjs/common'; +import { + Notification, + NotificationDeliveryHistory, + DeliveryStatus, + NotificationChannel as ChannelType, +} from '../entities/notification.entity'; +import { NotificationChannel } from '../channels/channel.interface'; +import { Inject } from '@nestjs/common'; + +interface NotificationJobData { + notificationId: string; + channel: string; + retryCount: number; +} + +@Processor('notifications') +export class NotificationProcessor { + private readonly logger = new Logger(NotificationProcessor.name); + + // Map of channel types to their implementations + private readonly channelMap: Map = new Map(); + + constructor( + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + @InjectRepository(NotificationDeliveryHistory) + private readonly deliveryHistoryRepository: Repository, + // Inject all channel implementations + private readonly channels: NotificationChannel[], + ) { + // Build the channel map for quick lookup + this.channels.forEach((channel) => { + this.channelMap.set(channel.channelType, channel); + }); + } + + @Process('deliver') + async processDelivery(job: Job) { + const { notificationId, channel: channelType, retryCount } = job.data; + + this.logger.debug( + `Processing delivery job for notification ${notificationId} via ${channelType}, attempt ${retryCount + 1}`, + ); + + // Get the channel implementation + const channel = this.channelMap.get(channelType); + if (!channel) { + this.logger.error(`Unknown channel type: ${channelType}`); + throw new Error(`Unknown channel: ${channelType}`); + } + + // Get the notification + const notification = await this.notificationRepository.findOne({ + where: { id: notificationId }, + }); + + if (!notification) { + this.logger.error(`Notification ${notificationId} not found`); + throw new Error(`Notification not found: ${notificationId}`); + } + + // Create or update delivery history + let deliveryHistory = await this.deliveryHistoryRepository.findOne({ + where: { + notificationId, + channel: channelType as ChannelType, + }, + }); + + if (!deliveryHistory) { + deliveryHistory = this.deliveryHistoryRepository.create({ + notificationId, + recipientId: notification.recipientId, + channel: channelType as ChannelType, + status: DeliveryStatus.RETRYING, + retryAttempts: retryCount, + }); + } else { + deliveryHistory.retryAttempts = retryCount; + deliveryHistory.status = DeliveryStatus.RETRYING; + } + await this.deliveryHistoryRepository.save(deliveryHistory); + + try { + // Check if channel is enabled for this user + const isEnabled = await channel.isEnabled(notification.recipientId); + if (!isEnabled) { + this.logger.debug( + `Channel ${channelType} is disabled for user ${notification.recipientId}, skipping delivery`, + ); + deliveryHistory.status = DeliveryStatus.DELIVERED; + await this.deliveryHistoryRepository.save(deliveryHistory); + return; + } + + // Send the notification + const result = await channel.send(notification); + + if (result.success) { + this.logger.debug( + `Successfully delivered notification ${notificationId} via ${channelType}`, + ); + deliveryHistory.status = DeliveryStatus.DELIVERED; + deliveryHistory.deliveredAt = result.deliveryTimestamp || new Date(); + await this.deliveryHistoryRepository.save(deliveryHistory); + } else { + this.logger.warn( + `Failed to deliver notification ${notificationId} via ${channelType}: ${result.error}`, + ); + deliveryHistory.status = DeliveryStatus.FAILED; + deliveryHistory.failureReason = result.error || 'Unknown error'; + await this.deliveryHistoryRepository.save(deliveryHistory); + throw new Error(result.error || 'Delivery failed'); + } + } catch (error) { + this.logger.error( + `Exception while delivering notification ${notificationId} via ${channelType}: ${error.message}`, + error.stack, + ); + deliveryHistory.status = DeliveryStatus.FAILED; + deliveryHistory.failureReason = error.message; + await this.deliveryHistoryRepository.save(deliveryHistory); + throw error; // This will trigger a retry according to queue settings + } + } + + @Process('dead-letter') + async processDeadLetter(job: Job) { + const { notificationId, channel: channelType } = job.data; + + this.logger.warn( + `Moving notification ${notificationId} to dead letter queue for channel ${channelType}`, + ); + + // Update delivery history to mark as dead letter + const deliveryHistory = await this.deliveryHistoryRepository.findOne({ + where: { + notificationId, + channel: channelType as ChannelType, + }, + }); + + if (deliveryHistory) { + deliveryHistory.status = DeliveryStatus.DEAD_LETTER; + await this.deliveryHistoryRepository.save(deliveryHistory); + } + } +} \ No newline at end of file diff --git a/src/notifications/services/notification.service.ts b/src/notifications/services/notification.service.ts deleted file mode 100644 index e52ae97..0000000 --- a/src/notifications/services/notification.service.ts +++ /dev/null @@ -1,457 +0,0 @@ -import { Injectable, Logger, Inject, OnModuleInit } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In, LessThanOrEqual, MoreThan, FindOptionsWhere } from 'typeorm'; -import { Queue } from 'bullmq'; -import { InjectQueue } from '@nestjs/bullmq'; -import { ConfigService } from '@nestjs/config'; -import { Notification } from '../entities/notification.entity'; -import { NotificationDelivery } from '../entities/notification-delivery.entity'; -import { UserNotificationPreference } from '../entities/user-notification-preference.entity'; -import { CreateNotificationDto } from '../dto/create-notification.dto'; -import { QueryNotificationsDto } from '../dto/query-notifications.dto'; -import { UpdateNotificationPreferencesDto } from '../dto/update-notification-preferences.dto'; -import { - NotificationType, - DeliveryChannel, - DeliveryStatus, - NotificationPriority, - NotificationFrequency, -} from '../enums/notification-type.enum'; -import { TemplateService } from './template.service'; -import { BaseDeliveryService } from './delivery/base-delivery.service'; -import { InAppDeliveryService } from './delivery/in-app-delivery.service'; -import { EmailDeliveryService } from './delivery/email-delivery.service'; -import { WebhookDeliveryService } from './delivery/webhook-delivery.service'; -import { PushDeliveryService } from './delivery/push-delivery.service'; -import { SmsDeliveryService } from './delivery/sms-delivery.service'; - -const DEFAULT_PAGE_LIMIT = 50; -const MAX_PAGE_LIMIT = 200; -const MAX_RETRIES = 5; -const NOTIFICATION_QUEUE = 'notifications-queue'; - -@Injectable() -export class NotificationService implements OnModuleInit { - private readonly logger = new Logger(NotificationService.name); - private readonly deliveryServices: Map = new Map(); - - private totalQueued = 0; - private totalDelivered = 0; - private totalFailed = 0; - - constructor( - @InjectRepository(Notification) - private readonly notificationRepo: Repository, - @InjectRepository(NotificationDelivery) - private readonly deliveryRepo: Repository, - @InjectRepository(UserNotificationPreference) - private readonly preferencesRepo: Repository, - private readonly templateService: TemplateService, - private readonly configService: ConfigService, - @InjectQueue(NOTIFICATION_QUEUE) - private readonly notificationQueue: Queue, - inAppDelivery: InAppDeliveryService, - emailDelivery: EmailDeliveryService, - webhookDelivery: WebhookDeliveryService, - pushDelivery: PushDeliveryService, - smsDelivery: SmsDeliveryService, - ) { - this.deliveryServices.set(DeliveryChannel.IN_APP, inAppDelivery); - this.deliveryServices.set(DeliveryChannel.EMAIL, emailDelivery); - this.deliveryServices.set(DeliveryChannel.WEBHOOK, webhookDelivery); - this.deliveryServices.set(DeliveryChannel.PUSH, pushDelivery); - this.deliveryServices.set(DeliveryChannel.SMS, smsDelivery); - } - - async onModuleInit() { - this.logger.log('NotificationService initialized'); - } - - async create(data: CreateNotificationDto): Promise { - const notification = this.notificationRepo.create({ - type: data.type, - userId: data.userId, - walletAddress: data.walletAddress, - title: data.title, - body: data.body, - data: data.data || {}, - priority: data.priority || NotificationPriority.NORMAL, - status: 'PENDING', - scheduledAt: data.scheduledAt ? new Date(data.scheduledAt) : undefined, - }); - - const saved = await this.notificationRepo.save(notification); - - const channels = data.channels || await this.resolveChannels(data.userId, data.type); - const preferences = await this.getOrCreatePreferences(data.userId); - - for (const channel of channels) { - const reason = this.evaluateChannelEligibility(channel, preferences, data.type); - if (reason) { - this.logger.debug(`Skipping ${channel} for notification ${saved.id}: ${reason}`); - continue; - } - - const destination = this.resolveDestination(channel, preferences); - const delivery = this.deliveryRepo.create({ - notificationId: saved.id, - channel, - status: DeliveryStatus.PENDING, - destination, - maxRetries: MAX_RETRIES, - queuedAt: new Date(), - }); - await this.deliveryRepo.save(delivery); - } - - await this.enqueueDelivery(saved.id); - this.totalQueued++; - - const result = await this.notificationRepo.findOne({ - where: { id: saved.id }, - relations: ['deliveries'], - }); - return result!; - } - - async enqueueDelivery(notificationId: string, delayMs = 0): Promise { - await this.notificationQueue.add( - 'deliver-notification', - { notificationId }, - { - delay: delayMs, - attempts: MAX_RETRIES, - backoff: { type: 'exponential', delay: 2000 }, - removeOnComplete: false, - removeOnFail: false, - }, - ); - } - - async processDelivery(notificationId: string): Promise { - const notification = await this.notificationRepo.findOne({ - where: { id: notificationId }, - relations: ['deliveries'], - }); - - if (!notification) { - this.logger.warn(`Notification ${notificationId} not found for delivery`); - return; - } - - const pendingDeliveries = notification.deliveries.filter( - (d) => d.status === DeliveryStatus.PENDING || d.status === DeliveryStatus.QUEUED, - ); - - if (pendingDeliveries.length === 0) { - this.logger.debug(`Notification ${notificationId} has no pending deliveries`); - return; - } - - notification.status = 'SENDING'; - await this.notificationRepo.save(notification); - - let allSucceeded = true; - - for (const delivery of pendingDeliveries) { - const service = this.deliveryServices.get(delivery.channel as DeliveryChannel); - if (!service) { - this.logger.warn(`No delivery service for channel ${delivery.channel}`); - delivery.status = DeliveryStatus.FAILED; - delivery.failureReason = `Unsupported channel: ${delivery.channel}`; - await this.deliveryRepo.save(delivery); - allSucceeded = false; - continue; - } - - delivery.status = DeliveryStatus.SENT; - delivery.sentAt = new Date(); - delivery.responseData = { - type: notification.type, - title: notification.title, - body: notification.body, - data: notification.data, - }; - await this.deliveryRepo.save(delivery); - - try { - const result = await service.deliver(delivery); - - if (result.success) { - delivery.status = DeliveryStatus.DELIVERED; - delivery.deliveredAt = result.deliveredAt || new Date(); - if (result.responseData) { - delivery.responseData = { ...delivery.responseData, ...result.responseData }; - } - this.totalDelivered++; - } else { - delivery.retryCount += 1; - delivery.status = delivery.retryCount >= delivery.maxRetries - ? DeliveryStatus.FAILED - : DeliveryStatus.PENDING; - delivery.failureReason = result.failureReason || null; - delivery.lastRetryAt = new Date(); - if (result.responseData) { - delivery.responseData = { ...delivery.responseData, ...result.responseData }; - } - allSucceeded = false; - this.totalFailed++; - - if (delivery.status === DeliveryStatus.PENDING) { - await this.enqueueDelivery(notificationId, 2000 * Math.pow(2, delivery.retryCount)); - } - } - - await this.deliveryRepo.save(delivery); - } catch (error) { - delivery.status = DeliveryStatus.FAILED; - delivery.failureReason = error instanceof Error ? error.message : 'Unknown delivery error'; - delivery.retryCount += 1; - delivery.lastRetryAt = new Date(); - allSucceeded = false; - this.totalFailed++; - await this.deliveryRepo.save(delivery); - } - } - - notification.status = allSucceeded ? 'DELIVERED' : 'PARTIALLY_DELIVERED'; - notification.sentAt = new Date(); - await this.notificationRepo.save(notification); - } - - async getUserNotifications( - userId: string, - query: QueryNotificationsDto, - ): Promise<{ notifications: Notification[]; total: number }> { - const where: FindOptionsWhere = { userId }; - const limit = Math.min(query.limit || DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT); - const offset = query.offset || 0; - - if (query.read !== undefined) where.read = query.read; - if (query.type) where.type = query.type; - if (query.status) where.status = query.status; - - const [notifications, total] = await this.notificationRepo.findAndCount({ - where, - order: { createdAt: 'DESC' }, - skip: offset, - take: limit, - relations: ['deliveries'], - }); - - return { notifications, total }; - } - - async markAsRead(notificationId: string, userId: string): Promise { - const notification = await this.notificationRepo.findOne({ - where: { id: notificationId, userId }, - }); - - if (!notification) { - throw new Error('Notification not found'); - } - - notification.read = true; - notification.readAt = new Date(); - return this.notificationRepo.save(notification); - } - - async markAllAsRead(userId: string): Promise { - const result = await this.notificationRepo.update( - { userId, read: false }, - { read: true, readAt: new Date() }, - ); - return result.affected || 0; - } - - async getDeliveryHistory( - notificationId: string, - ): Promise { - return this.deliveryRepo.find({ - where: { notificationId }, - order: { createdAt: 'ASC' }, - }); - } - - async getUnreadCount(userId: string): Promise { - return this.notificationRepo.count({ - where: { userId, read: false }, - }); - } - - async getOrCreatePreferences(userId: string): Promise { - let preferences = await this.preferencesRepo.findOne({ where: { userId } }); - - if (!preferences) { - preferences = this.preferencesRepo.create({ - userId, - enabledChannels: [DeliveryChannel.IN_APP], - frequency: NotificationFrequency.INSTANT, - notificationsEnabled: true, - subscribedCategories: [], - unsubscribedCategories: [], - }); - preferences = await this.preferencesRepo.save(preferences); - } - - return preferences; - } - - async updatePreferences( - userId: string, - data: UpdateNotificationPreferencesDto, - ): Promise { - const preferences = await this.getOrCreatePreferences(userId); - - if (data.enabledChannels !== undefined) preferences.enabledChannels = data.enabledChannels.map(c => c.toString()); - if (data.frequency !== undefined) preferences.frequency = data.frequency; - if (data.quietHoursStart !== undefined) preferences.quietHoursStart = [data.quietHoursStart] as string[]; - if (data.quietHoursEnd !== undefined) preferences.quietHoursEnd = [data.quietHoursEnd] as string[]; - if (data.subscribedCategories !== undefined) preferences.subscribedCategories = data.subscribedCategories; - if (data.unsubscribedCategories !== undefined) preferences.unsubscribedCategories = data.unsubscribedCategories; - if (data.digestEnabled !== undefined) preferences.digestEnabled = data.digestEnabled; - if (data.digestFrequency !== undefined) preferences.digestFrequency = data.digestFrequency; - if (data.emailAddress !== undefined) preferences.emailAddress = data.emailAddress; - if (data.webhookUrl !== undefined) preferences.webhookUrl = data.webhookUrl; - if (data.pushToken !== undefined) preferences.pushToken = data.pushToken; - if (data.notificationsEnabled !== undefined) preferences.notificationsEnabled = data.notificationsEnabled; - - return this.preferencesRepo.save(preferences); - } - - async getPreferences(userId: string): Promise { - return this.getOrCreatePreferences(userId); - } - - async scheduleNotification(data: CreateNotificationDto): Promise { - if (!data.scheduledAt) { - data.scheduledAt = new Date(Date.now() + 3600000).toISOString(); - } - return this.create(data); - } - - async cancelScheduled(notificationId: string, userId: string): Promise { - const notification = await this.notificationRepo.findOne({ - where: { id: notificationId, userId }, - }); - - if (!notification) { - throw new Error('Notification not found'); - } - - if (notification.status !== 'PENDING') { - throw new Error('Can only cancel pending notifications'); - } - - notification.status = 'CANCELLED'; - await this.notificationRepo.save(notification); - - await this.deliveryRepo.update( - { notificationId, status: DeliveryStatus.PENDING }, - { status: DeliveryStatus.CANCELLED }, - ); - - return notification; - } - - async getMetrics(): Promise<{ - queued: number; - delivered: number; - failed: number; - queueDepth: number; - }> { - const queueDepth = await this.notificationQueue.getWaitingCount() - + await this.notificationQueue.getActiveCount() - + await this.notificationQueue.getDelayedCount(); - - const failedDeliveries = await this.deliveryRepo.count({ - where: { status: DeliveryStatus.FAILED }, - }); - - return { - queued: this.totalQueued, - delivered: this.totalDelivered, - failed: failedDeliveries || this.totalFailed, - queueDepth, - }; - } - - private async resolveChannels( - userId: string, - type: NotificationType, - ): Promise { - const preferences = await this.getOrCreatePreferences(userId); - - if (!preferences.notificationsEnabled) { - return []; - } - - const categories = preferences.subscribedCategories || []; - if (categories.length > 0 && !categories.includes(type)) { - const unsubscribed = preferences.unsubscribedCategories || []; - if (unsubscribed.includes(type)) { - return []; - } - } - - if (preferences.frequency === NotificationFrequency.INSTANT) { - const channels = preferences.enabledChannels.map((c) => c as DeliveryChannel); - return channels.length > 0 ? channels : [DeliveryChannel.IN_APP]; - } - - return []; - } - - private evaluateChannelEligibility( - channel: DeliveryChannel, - preferences: UserNotificationPreference, - type: NotificationType, - ): string | null { - const enabledChannels = (preferences.enabledChannels || []).map(c => c as DeliveryChannel); - if (!enabledChannels.includes(channel)) { - return `Channel ${channel} not enabled`; - } - - const unsubscribed = preferences.unsubscribedCategories || []; - if (unsubscribed.includes(type)) { - return `Category ${type} is unsubscribed`; - } - - if (preferences.quietHoursStart && preferences.quietHoursStart.length > 0 && preferences.quietHoursEnd && preferences.quietHoursEnd.length > 0) { - const now = new Date(); - const hours = now.getHours(); - const minutes = now.getMinutes(); - const current = hours * 60 + minutes; - const startParts = preferences.quietHoursStart[0].split(':').map(Number); - const endParts = preferences.quietHoursEnd[0].split(':').map(Number); - const start = startParts[0] * 60 + (startParts[1] || 0); - const end = endParts[0] * 60 + (endParts[1] || 0); - - if (start <= end) { - if (current >= start && current < end) return 'Quiet hours active'; - } else { - if (current >= start || current < end) return 'Quiet hours active'; - } - } - - return null; - } - - private resolveDestination( - channel: DeliveryChannel, - preferences: UserNotificationPreference, - ): string | undefined { - switch (channel) { - case DeliveryChannel.EMAIL: - return preferences.emailAddress; - case DeliveryChannel.WEBHOOK: - return preferences.webhookUrl; - case DeliveryChannel.PUSH: - return preferences.pushToken; - case DeliveryChannel.SMS: - return undefined; - default: - return undefined; - } - } -} diff --git a/src/notifications/websockets/websocket.gateway.ts b/src/notifications/websockets/websocket.gateway.ts new file mode 100644 index 0000000..e66516b --- /dev/null +++ b/src/notifications/websockets/websocket.gateway.ts @@ -0,0 +1,138 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + OnGatewayConnection, + OnGatewayDisconnect, + ConnectedSocket, + MessageBody, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { Logger } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { Notification } from '../entities/notification.entity'; + +interface AuthenticatedSocket extends Socket { + userId?: string; +} + +@WebSocketGateway({ + cors: { + origin: '*', // Configure this appropriately for production + }, + transports: ['websocket', 'polling'], +}) +export class NotificationGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server; + + private readonly logger = new Logger(NotificationGateway.name); + private userSockets: Map> = new Map(); // userId -> Set + + constructor( + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async handleConnection(@ConnectedSocket() client: AuthenticatedSocket) { + try { + const token = client.handshake.auth.token || client.handshake.query.token; + + if (!token) { + this.logger.warn(`Client ${client.id} connected without token`); + client.disconnect(); + return; + } + + const payload = this.jwtService.verify(token, { + secret: this.configService.get('JWT_SECRET'), + }); + + const userId = payload.sub; + client.userId = userId; + + // Add socket to user's set + if (!this.userSockets.has(userId)) { + this.userSockets.set(userId, new Set()); + } + this.userSockets.get(userId).add(client.id); + + this.logger.log(`Client ${client.id} authenticated for user ${userId}`); + + // Send connection confirmation + client.emit('connected', { userId, timestamp: new Date() }); + + } catch (error) { + this.logger.warn(`Invalid token from client ${client.id}: ${error.message}`); + client.disconnect(); + } + } + + handleDisconnect(@ConnectedSocket() client: AuthenticatedSocket) { + if (client.userId) { + const userSocketSet = this.userSockets.get(client.userId); + if (userSocketSet) { + userSocketSet.delete(client.id); + if (userSocketSet.size === 0) { + this.userSockets.delete(client.userId); + } + } + this.logger.log(`Client ${client.id} disconnected for user ${client.userId}`); + } + } + + /** + * Broadcast a notification to a specific user's connected clients + */ + sendToUser(userId: string, notification: Notification) { + const userSocketSet = this.userSockets.get(userId); + if (!userSocketSet || userSocketSet.size === 0) { + this.logger.debug(`No active connections for user ${userId}, notification queued`); + return false; + } + + this.logger.debug( + `Broadcasting notification ${notification.id} to ${userSocketSet.size} clients for user ${userId}`, + ); + + userSocketSet.forEach((socketId) => { + this.server.to(socketId).emit('notification', notification); + }); + + return true; + } + + /** + * Check if a user is currently online + */ + isUserOnline(userId: string): boolean { + const userSocketSet = this.userSockets.get(userId); + return userSocketSet && userSocketSet.size > 0; + } + + /** + * Get number of connected users + */ + getConnectedUsersCount(): number { + return this.userSockets.size; + } + + @SubscribeMessage('subscribe') + handleSubscribe(@ConnectedSocket() client: AuthenticatedSocket, @MessageBody() channels: string[]) { + this.logger.debug(`Client ${client.id} subscribed to channels: ${channels.join(', ')}`); + channels.forEach((channel) => client.join(channel)); + } + + @SubscribeMessage('unsubscribe') + handleUnsubscribe(@ConnectedSocket() client: AuthenticatedSocket, @MessageBody() channels: string[]) { + this.logger.debug(`Client ${client.id} unsubscribed from channels: ${channels.join(', ')}`); + channels.forEach((channel) => client.leave(channel)); + } + + @SubscribeMessage('mark_read') + handleMarkRead(@ConnectedSocket() client: AuthenticatedSocket, @MessageBody() notificationId: string) { + // This will be handled by the notification service to update the database + this.logger.debug(`User ${client.userId} marked notification ${notificationId} as read`); + } +} \ No newline at end of file