diff --git a/.env.example b/.env.example index 8282899..3bd2bd7 100644 --- a/.env.example +++ b/.env.example @@ -113,3 +113,18 @@ DATABASE_URL=file:./dev.db JWT_SECRET=your-super-secret-jwt-key-change-in-production # JWT token expiration time (e.g., '7d', '24h', '60m') JWT_EXPIRATION=7d + +# ============================================== +# Notification Service Configuration +# ============================================== +# SMTP configuration for email delivery +SMTP_HOST=localhost +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM=noreply@truthbounty.com + +# Notification queue configuration +NOTIFICATION_QUEUE_DELAY=0 +NOTIFICATION_MAX_RETRIES=5 +NOTIFICATION_RETRY_DELAY=2000 diff --git a/src/notifications/dto/create-notification.dto.ts b/src/notifications/dto/create-notification.dto.ts new file mode 100644 index 0000000..4766531 --- /dev/null +++ b/src/notifications/dto/create-notification.dto.ts @@ -0,0 +1,69 @@ +import { + IsString, + IsEnum, + IsOptional, + IsObject, + IsArray, + IsDateString, + IsUUID, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + NotificationType, + DeliveryChannel, + NotificationPriority, +} from '../enums/notification-type.enum'; + +export class CreateNotificationDto { + @ApiProperty({ enum: NotificationType }) + @IsEnum(NotificationType) + type: NotificationType; + + @ApiProperty() + @IsUUID() + userId: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + walletAddress?: string; + + @ApiProperty() + @IsString() + title: string; + + @ApiProperty() + @IsString() + body: string; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + data?: Record; + + @ApiPropertyOptional({ enum: NotificationPriority }) + @IsEnum(NotificationPriority) + @IsOptional() + priority?: NotificationPriority; + + @ApiPropertyOptional({ enum: DeliveryChannel, isArray: true }) + @IsArray() + @IsEnum(DeliveryChannel, { each: true }) + @IsOptional() + channels?: DeliveryChannel[]; + + @ApiPropertyOptional() + @IsDateString() + @IsOptional() + scheduledAt?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + templateName?: string; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + templateVariables?: Record; +} diff --git a/src/notifications/dto/notification-response.dto.ts b/src/notifications/dto/notification-response.dto.ts new file mode 100644 index 0000000..22a59b0 --- /dev/null +++ b/src/notifications/dto/notification-response.dto.ts @@ -0,0 +1,83 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + NotificationType, + NotificationPriority, + DeliveryChannel, + DeliveryStatus, +} from '../enums/notification-type.enum'; + +export class DeliveryResponseDto { + @ApiProperty() + id: string; + + @ApiProperty({ enum: DeliveryChannel }) + channel: DeliveryChannel; + + @ApiProperty({ enum: DeliveryStatus }) + status: DeliveryStatus; + + @ApiPropertyOptional() + destination?: string; + + @ApiProperty() + retryCount: number; + + @ApiPropertyOptional() + failureReason?: string; + + @ApiPropertyOptional() + sentAt?: Date; + + @ApiPropertyOptional() + deliveredAt?: Date; + + @ApiProperty() + createdAt: Date; +} + +export class NotificationResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + userId: string; + + @ApiPropertyOptional() + walletAddress?: string; + + @ApiProperty({ enum: NotificationType }) + type: NotificationType; + + @ApiProperty() + title: string; + + @ApiProperty() + body: string; + + @ApiPropertyOptional() + data?: Record; + + @ApiProperty({ enum: NotificationPriority }) + priority: NotificationPriority; + + @ApiProperty() + read: boolean; + + @ApiPropertyOptional() + readAt?: Date; + + @ApiProperty() + status: string; + + @ApiPropertyOptional() + scheduledAt?: Date; + + @ApiPropertyOptional() + sentAt?: Date; + + @ApiProperty() + createdAt: Date; + + @ApiPropertyOptional({ type: [DeliveryResponseDto] }) + deliveries?: DeliveryResponseDto[]; +} diff --git a/src/notifications/dto/query-notifications.dto.ts b/src/notifications/dto/query-notifications.dto.ts new file mode 100644 index 0000000..71f213a --- /dev/null +++ b/src/notifications/dto/query-notifications.dto.ts @@ -0,0 +1,42 @@ +import { IsOptional, IsBoolean, IsEnum, IsString, IsInt, Min, Max } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { NotificationType, DeliveryChannel } from '../enums/notification-type.enum'; + +export class QueryNotificationsDto { + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + read?: boolean; + + @ApiPropertyOptional({ enum: NotificationType }) + @IsOptional() + @IsEnum(NotificationType) + type?: NotificationType; + + @ApiPropertyOptional({ enum: DeliveryChannel }) + @IsOptional() + @IsEnum(DeliveryChannel) + channel?: DeliveryChannel; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + status?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + @Type(() => Number) + limit?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(0) + @Type(() => Number) + offset?: number; +} diff --git a/src/notifications/dto/update-notification-preferences.dto.ts b/src/notifications/dto/update-notification-preferences.dto.ts new file mode 100644 index 0000000..85a7e38 --- /dev/null +++ b/src/notifications/dto/update-notification-preferences.dto.ts @@ -0,0 +1,79 @@ +import { + IsString, + IsEnum, + IsOptional, + IsArray, + IsBoolean, + IsEmail, + IsUrl, +} from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + DeliveryChannel, + NotificationFrequency, +} from '../enums/notification-type.enum'; + +export class UpdateNotificationPreferencesDto { + @ApiPropertyOptional({ enum: DeliveryChannel, isArray: true }) + @IsArray() + @IsEnum(DeliveryChannel, { each: true }) + @IsOptional() + enabledChannels?: DeliveryChannel[]; + + @ApiPropertyOptional({ enum: NotificationFrequency }) + @IsEnum(NotificationFrequency) + @IsOptional() + frequency?: NotificationFrequency; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + quietHoursStart?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + quietHoursEnd?: string; + + @ApiPropertyOptional({ isArray: true }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + subscribedCategories?: string[]; + + @ApiPropertyOptional({ isArray: true }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + unsubscribedCategories?: string[]; + + @ApiPropertyOptional() + @IsBoolean() + @IsOptional() + digestEnabled?: boolean; + + @ApiPropertyOptional({ enum: NotificationFrequency }) + @IsEnum(NotificationFrequency) + @IsOptional() + digestFrequency?: NotificationFrequency; + + @ApiPropertyOptional() + @IsEmail() + @IsOptional() + emailAddress?: string; + + @ApiPropertyOptional() + @IsUrl() + @IsOptional() + webhookUrl?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + pushToken?: string; + + @ApiPropertyOptional() + @IsBoolean() + @IsOptional() + notificationsEnabled?: boolean; +} diff --git a/src/notifications/entities/notification-delivery.entity.ts b/src/notifications/entities/notification-delivery.entity.ts new file mode 100644 index 0000000..8263ebb --- /dev/null +++ b/src/notifications/entities/notification-delivery.entity.ts @@ -0,0 +1,68 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Notification } from './notification.entity'; +import { DeliveryChannel, DeliveryStatus } from '../enums/notification-type.enum'; + +@Entity('notification_deliveries') +@Index(['notificationId']) +@Index(['channel']) +@Index(['status']) +@Index(['createdAt']) +export class NotificationDelivery { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + notificationId: string; + + @ManyToOne(() => Notification, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'notificationId' }) + notification: Notification; + + @Column({ type: 'varchar', enum: DeliveryChannel }) + channel: DeliveryChannel; + + @Column({ type: 'varchar', enum: DeliveryStatus, default: DeliveryStatus.PENDING }) + status: DeliveryStatus; + + @Column({ type: 'text', nullable: true }) + destination: string | null; + + @Column({ type: 'int', default: 0 }) + retryCount: number; + + @Column({ type: 'int', default: 0 }) + maxRetries: number; + + @Column({ type: 'text', nullable: true }) + failureReason: string | null; + + @Column({ type: 'json', nullable: true }) + responseData: Record; + + @Column({ type: 'datetime', nullable: true }) + queuedAt: Date; + + @Column({ type: 'datetime', nullable: true }) + sentAt: Date; + + @Column({ type: 'datetime', nullable: true }) + deliveredAt: Date; + + @Column({ type: 'datetime', nullable: true }) + lastRetryAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/notifications/entities/notification-template.entity.ts b/src/notifications/entities/notification-template.entity.ts new file mode 100644 index 0000000..f49489c --- /dev/null +++ b/src/notifications/entities/notification-template.entity.ts @@ -0,0 +1,53 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; +import { NotificationType } from '../enums/notification-type.enum'; + +@Entity('notification_templates') +@Index(['name']) +@Index(['type']) +export class NotificationTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + name: string; + + @Column({ type: 'varchar', enum: NotificationType }) + type: NotificationType; + + @Column({ type: 'text' }) + subjectTemplate: string; + + @Column({ type: 'text' }) + bodyTemplate: string; + + @Column({ type: 'text', nullable: true }) + htmlTemplate: string; + + @Column({ type: 'text', nullable: true }) + markdownTemplate: string; + + @Column({ type: 'simple-array', default: '' }) + variables: string[]; + + @Column({ type: 'varchar', default: 'en' }) + locale: string; + + @Column({ type: 'int', default: 1 }) + version: number; + + @Column({ default: true }) + active: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/notifications/entities/notification.entity.ts b/src/notifications/entities/notification.entity.ts new file mode 100644 index 0000000..71532be --- /dev/null +++ b/src/notifications/entities/notification.entity.ts @@ -0,0 +1,77 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + 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'; + +@Entity('notifications') +@Index(['userId', 'createdAt']) +@Index(['userId', 'readAt']) +@Index(['type']) +@Index(['status']) +export class Notification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ nullable: true }) + walletAddress: string; + + @Column({ type: 'varchar', enum: NotificationType }) + type: NotificationType; + + @Column() + title: string; + + @Column({ type: 'text' }) + body: string; + + @Column({ type: 'json', nullable: true }) + data: Record; + + @Column({ type: 'varchar', enum: NotificationPriority, default: NotificationPriority.NORMAL }) + priority: NotificationPriority; + + @Column({ default: false }) + read: boolean; + + @Column({ type: 'datetime', nullable: true }) + readAt: Date; + + @Column({ type: 'varchar', default: 'PENDING' }) + status: string; + + @Column({ type: 'datetime', nullable: true }) + scheduledAt: Date; + + @Column({ type: 'datetime', nullable: true }) + sentAt: Date; + + @OneToMany(() => NotificationDelivery, (delivery) => delivery.notification, { cascade: true }) + deliveries: NotificationDelivery[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/notifications/entities/user-notification-preference.entity.ts b/src/notifications/entities/user-notification-preference.entity.ts new file mode 100644 index 0000000..6625457 --- /dev/null +++ b/src/notifications/entities/user-notification-preference.entity.ts @@ -0,0 +1,79 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../entities/user.entity'; +import { + DeliveryChannel, + NotificationType, + NotificationFrequency, +} from '../enums/notification-type.enum'; + +@Entity('user_notification_preferences') +@Index(['userId']) +export class UserNotificationPreference { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + userId: string; + + @OneToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ type: 'simple-array', default: 'IN_APP' }) + enabledChannels: string[]; + + @Column({ + type: 'varchar', + enum: NotificationFrequency, + default: NotificationFrequency.INSTANT, + }) + frequency: NotificationFrequency; + + @Column({ type: 'simple-array', nullable: true }) + quietHoursStart: string[] | null; + + @Column({ type: 'simple-array', nullable: true }) + quietHoursEnd: string[] | null; + + @Column({ type: 'simple-array', nullable: true }) + subscribedCategories: string[]; + + @Column({ type: 'simple-array', nullable: true }) + unsubscribedCategories: string[]; + + @Column({ default: false }) + digestEnabled: boolean; + + @Column({ type: 'varchar', enum: NotificationFrequency, nullable: true }) + digestFrequency: NotificationFrequency; + + @Column({ nullable: true }) + emailAddress: string; + + @Column({ nullable: true }) + webhookUrl: string; + + @Column({ nullable: true }) + pushToken: string; + + @Column({ type: 'simple-array', nullable: true }) + pushPlatforms: string[]; + + @Column({ default: true }) + notificationsEnabled: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/notifications/enums/notification-type.enum.ts b/src/notifications/enums/notification-type.enum.ts new file mode 100644 index 0000000..7684772 --- /dev/null +++ b/src/notifications/enums/notification-type.enum.ts @@ -0,0 +1,47 @@ +export enum NotificationType { + CLAIM_SUBMITTED = 'CLAIM_SUBMITTED', + VERIFICATION_ASSIGNED = 'VERIFICATION_ASSIGNED', + VERIFICATION_COMPLETED = 'VERIFICATION_COMPLETED', + DISPUTE_OPENED = 'DISPUTE_OPENED', + DISPUTE_RESOLVED = 'DISPUTE_RESOLVED', + REWARDS_DISTRIBUTED = 'REWARDS_DISTRIBUTED', + GOVERNANCE_PROPOSAL_CREATED = 'GOVERNANCE_PROPOSAL_CREATED', + GOVERNANCE_VOTING_REMINDER = 'GOVERNANCE_VOTING_REMINDER', + REPUTATION_UPDATE = 'REPUTATION_UPDATE', + STAKING_EVENT = 'STAKING_EVENT', + TREASURY_ANNOUNCEMENT = 'TREASURY_ANNOUNCEMENT', + MODERATOR_ACTION = 'MODERATOR_ACTION', + SECURITY_ALERT = 'SECURITY_ALERT', + SYSTEM_MAINTENANCE = 'SYSTEM_MAINTENANCE', +} + +export enum DeliveryChannel { + IN_APP = 'IN_APP', + EMAIL = 'EMAIL', + WEBHOOK = 'WEBHOOK', + PUSH = 'PUSH', + SMS = 'SMS', +} + +export enum DeliveryStatus { + PENDING = 'PENDING', + QUEUED = 'QUEUED', + SENT = 'SENT', + DELIVERED = 'DELIVERED', + FAILED = 'FAILED', + CANCELLED = 'CANCELLED', +} + +export enum NotificationPriority { + LOW = 'LOW', + NORMAL = 'NORMAL', + HIGH = 'HIGH', + URGENT = 'URGENT', +} + +export enum NotificationFrequency { + INSTANT = 'INSTANT', + HOURLY = 'HOURLY', + DAILY = 'DAILY', + WEEKLY = 'WEEKLY', +} \ No newline at end of file diff --git a/src/notifications/interfaces/notification-payload.interface.ts b/src/notifications/interfaces/notification-payload.interface.ts new file mode 100644 index 0000000..cea2aaa --- /dev/null +++ b/src/notifications/interfaces/notification-payload.interface.ts @@ -0,0 +1,23 @@ +import { NotificationType, DeliveryChannel } from '../enums/notification-type.enum'; + +export interface NotificationPayload { + type: NotificationType; + userId: string; + walletAddress?: string; + title: string; + body: string; + data?: Record; + priority?: number; + channels?: DeliveryChannel[]; + scheduledAt?: Date; + templateName?: string; + templateVariables?: Record; +} + +export interface DeliveryResult { + success: boolean; + channel: DeliveryChannel; + deliveredAt?: Date; + failureReason?: string; + responseData?: Record; +} \ No newline at end of file diff --git a/src/notifications/notification.controller.ts b/src/notifications/notification.controller.ts new file mode 100644 index 0000000..7d136d8 --- /dev/null +++ b/src/notifications/notification.controller.ts @@ -0,0 +1,257 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + Query, + HttpCode, + HttpStatus, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { NotificationService } from './services/notification.service'; +import { TemplateService } from './services/template.service'; +import { CreateNotificationDto } from './dto/create-notification.dto'; +import { UpdateNotificationPreferencesDto } from './dto/update-notification-preferences.dto'; +import { QueryNotificationsDto } from './dto/query-notifications.dto'; +import { NotificationResponseDto } from './dto/notification-response.dto'; +import { Notification } from './entities/notification.entity'; +import { NotificationTemplate } from './entities/notification-template.entity'; +import { UserNotificationPreference } from './entities/user-notification-preference.entity'; +import { NotificationDelivery } from './entities/notification-delivery.entity'; +import { NotificationType } from './enums/notification-type.enum'; + +@ApiTags('notifications') +@ApiBearerAuth() +@Controller('notifications') +export class NotificationController { + private readonly logger = new Logger(NotificationController.name); + + constructor( + private readonly notificationService: NotificationService, + private readonly templateService: TemplateService, + ) {} + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Create and send a notification' }) + @ApiResponse({ status: 201, type: NotificationResponseDto }) + async create(@Body() dto: CreateNotificationDto): Promise { + return this.notificationService.create(dto); + } + + @Get(':userId') + @ApiOperation({ summary: 'Get notifications for a user' }) + @ApiResponse({ status: 200 }) + async getUserNotifications( + @Param('userId') userId: string, + @Query() query: QueryNotificationsDto, + ): Promise<{ notifications: Notification[]; total: number }> { + return this.notificationService.getUserNotifications(userId, query); + } + + @Get(':userId/unread-count') + @ApiOperation({ summary: 'Get unread notification count for a user' }) + @ApiResponse({ status: 200 }) + async getUnreadCount( + @Param('userId') userId: string, + ): Promise<{ count: number }> { + const count = await this.notificationService.getUnreadCount(userId); + return { count }; + } + + @Patch(':notificationId/read') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Mark a notification as read' }) + @ApiResponse({ status: 200, type: NotificationResponseDto }) + async markAsRead( + @Param('notificationId') notificationId: string, + @Query('userId') userId: string, + ): Promise { + if (!userId) throw new BadRequestException('userId query parameter is required'); + try { + return await this.notificationService.markAsRead(notificationId, userId); + } catch (error) { + throw new NotFoundException(error.message); + } + } + + @Patch(':userId/read-all') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Mark all notifications as read for a user' }) + @ApiResponse({ status: 200 }) + async markAllAsRead( + @Param('userId') userId: string, + ): Promise<{ count: number }> { + const count = await this.notificationService.markAllAsRead(userId); + return { count }; + } + + @Get(':notificationId/deliveries') + @ApiOperation({ summary: 'Get delivery history for a notification' }) + @ApiResponse({ status: 200 }) + async getDeliveryHistory( + @Param('notificationId') notificationId: string, + ): Promise { + return this.notificationService.getDeliveryHistory(notificationId); + } + + @Post('schedule') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Schedule a notification for later delivery' }) + @ApiResponse({ status: 201, type: NotificationResponseDto }) + async schedule(@Body() dto: CreateNotificationDto): Promise { + if (!dto.scheduledAt) { + throw new BadRequestException('scheduledAt is required for scheduled notifications'); + } + return this.notificationService.scheduleNotification(dto); + } + + @Patch(':notificationId/cancel') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Cancel a scheduled notification' }) + @ApiResponse({ status: 200, type: NotificationResponseDto }) + async cancelScheduled( + @Param('notificationId') notificationId: string, + @Query('userId') userId: string, + ): Promise { + if (!userId) throw new BadRequestException('userId query parameter is required'); + try { + return await this.notificationService.cancelScheduled(notificationId, userId); + } catch (error) { + if (error.message.includes('not found')) throw new NotFoundException(error.message); + throw new BadRequestException(error.message); + } + } + + @Get('preferences/:userId') + @ApiOperation({ summary: 'Get notification preferences for a user' }) + @ApiResponse({ status: 200 }) + async getPreferences( + @Param('userId') userId: string, + ): Promise { + return this.notificationService.getPreferences(userId); + } + + @Patch('preferences/:userId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Update notification preferences for a user' }) + @ApiResponse({ status: 200 }) + async updatePreferences( + @Param('userId') userId: string, + @Body() dto: UpdateNotificationPreferencesDto, + ): Promise { + return this.notificationService.updatePreferences(userId, dto); + } + + @Get('admin/metrics') + @ApiOperation({ summary: 'Get notification service metrics' }) + @ApiResponse({ status: 200 }) + async getMetrics(): Promise<{ + queued: number; + delivered: number; + failed: number; + queueDepth: number; + }> { + return this.notificationService.getMetrics(); + } + + @Post('admin/seed-templates') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Seed default notification templates' }) + @ApiResponse({ status: 201 }) + async seedTemplates(): Promise<{ count: number }> { + const templates: Array<{ + name: string; + type: NotificationType; + subjectTemplate: string; + bodyTemplate: string; + variables: string[]; + }> = [ + { + name: 'claim-submitted', + type: NotificationType.CLAIM_SUBMITTED, + subjectTemplate: 'Claim Submitted: {{claimTitle}}', + bodyTemplate: 'Your claim "{{claimTitle}}" has been submitted successfully and is pending verification.', + variables: ['claimTitle', 'claimId'], + }, + { + name: 'verification-assigned', + type: NotificationType.VERIFICATION_ASSIGNED, + subjectTemplate: 'Verification Requested: {{claimTitle}}', + bodyTemplate: 'You have been assigned to verify the claim "{{claimTitle}}". Please submit your verification.', + variables: ['claimTitle', 'claimId', 'deadline'], + }, + { + name: 'dispute-opened', + type: NotificationType.DISPUTE_OPENED, + subjectTemplate: 'Dispute Opened: {{claimTitle}}', + bodyTemplate: 'A dispute has been opened on claim "{{claimTitle}}". Review the details and participate in resolution.', + variables: ['claimTitle', 'claimId', 'disputeId'], + }, + { + name: 'rewards-distributed', + type: NotificationType.REWARDS_DISTRIBUTED, + subjectTemplate: 'Rewards Distributed: {{amount}}', + bodyTemplate: 'You have received {{amount}} tokens as a reward for your participation.', + variables: ['amount', 'tokenSymbol', 'reason'], + }, + { + name: 'governance-proposal', + type: NotificationType.GOVERNANCE_PROPOSAL_CREATED, + subjectTemplate: 'New Governance Proposal: {{proposalTitle}}', + bodyTemplate: 'A new governance proposal "{{proposalTitle}}" has been created. Cast your vote.', + variables: ['proposalTitle', 'proposalId', 'deadline'], + }, + { + name: 'reputation-update', + type: NotificationType.REPUTATION_UPDATE, + subjectTemplate: 'Reputation Updated: {{newScore}}', + bodyTemplate: 'Your reputation score has been updated to {{newScore}} ({{change}}).', + variables: ['newScore', 'change', 'reason'], + }, + { + name: 'staking-event', + type: NotificationType.STAKING_EVENT, + subjectTemplate: 'Staking Event: {{eventType}}', + bodyTemplate: 'A staking event has occurred: {{eventType}} of {{amount}} tokens.', + variables: ['eventType', 'amount', 'tokenSymbol'], + }, + { + name: 'security-alert', + type: NotificationType.SECURITY_ALERT, + subjectTemplate: 'Security Alert: {{alertType}}', + bodyTemplate: 'Security alert: {{alertType}}. {{description}}', + variables: ['alertType', 'description', 'action'], + }, + { + name: 'system-maintenance', + type: NotificationType.SYSTEM_MAINTENANCE, + subjectTemplate: 'System Maintenance: {{startTime}}', + bodyTemplate: 'Scheduled maintenance from {{startTime}} to {{endTime}}. {{description}}', + variables: ['startTime', 'endTime', 'description'], + }, + ]; + + let count = 0; + for (const tpl of templates) { + try { + await this.templateService.createTemplate(tpl); + count++; + } catch (err) { + this.logger.warn(`Template ${tpl.name} may already exist: ${err.message}`); + } + } + + return { count }; + } +} diff --git a/src/notifications/notification.module.ts b/src/notifications/notification.module.ts new file mode 100644 index 0000000..6ec881d --- /dev/null +++ b/src/notifications/notification.module.ts @@ -0,0 +1,53 @@ +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/notification.processor.spec.ts b/src/notifications/notification.processor.spec.ts new file mode 100644 index 0000000..c5dca68 --- /dev/null +++ b/src/notifications/notification.processor.spec.ts @@ -0,0 +1,121 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Job } from 'bullmq'; +import { NotificationProcessor } from './notification.processor'; +import { NotificationService } from './services/notification.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { getQueueToken } from '@nestjs/bullmq'; +import { ConfigService } from '@nestjs/config'; +import { Repository } from 'typeorm'; +import { Notification } from './entities/notification.entity'; +import { NotificationDelivery } from './entities/notification-delivery.entity'; +import { UserNotificationPreference } from './entities/user-notification-preference.entity'; +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'; + +describe('NotificationProcessor', () => { + let processor: NotificationProcessor; + let notificationService: NotificationService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationService, + NotificationProcessor, + { + provide: getRepositoryToken(Notification), + useClass: Repository, + }, + { + provide: getRepositoryToken(NotificationDelivery), + useClass: Repository, + }, + { + provide: getRepositoryToken(UserNotificationPreference), + useClass: Repository, + }, + { + provide: TemplateService, + useValue: { + render: jest.fn(), + createTemplate: jest.fn(), + }, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn(() => undefined), + }, + }, + { + provide: getQueueToken('notifications-queue'), + useValue: { + add: jest.fn(), + getWaitingCount: jest.fn().mockResolvedValue(0), + getActiveCount: jest.fn().mockResolvedValue(0), + getDelayedCount: jest.fn().mockResolvedValue(0), + }, + }, + InAppDeliveryService, + EmailDeliveryService, + WebhookDeliveryService, + PushDeliveryService, + SmsDeliveryService, + ], + }).compile(); + + processor = module.get(NotificationProcessor); + notificationService = module.get(NotificationService); + }); + + it('should be defined', () => { + expect(processor).toBeDefined(); + }); + + describe('process', () => { + it('should process deliver-notification job', async () => { + const processDeliverySpy = jest.spyOn(notificationService, 'processDelivery').mockResolvedValue(undefined); + + const mockJob = { + id: 'job-1', + name: 'deliver-notification', + data: { notificationId: 'notif-1' }, + } as Job; + + const result = await processor.process(mockJob); + + expect(processDeliverySpy).toHaveBeenCalledWith('notif-1'); + expect(result).toEqual({ success: true, notificationId: 'notif-1' }); + }); + + it('should throw error for unknown job name', async () => { + const mockJob = { + id: 'job-2', + name: 'unknown-job', + data: {}, + } as Job; + + await expect(processor.process(mockJob)).rejects.toThrow('Unknown notification job name: unknown-job'); + }); + + it('should handle multiple concurrent deliveries', async () => { + const processDeliverySpy = jest.spyOn(notificationService, 'processDelivery').mockResolvedValue(undefined); + + const jobs = [ + { id: 'job-1', name: 'deliver-notification', data: { notificationId: 'notif-1' } }, + { id: 'job-2', name: 'deliver-notification', data: { notificationId: 'notif-2' } }, + { id: 'job-3', name: 'deliver-notification', data: { notificationId: 'notif-3' } }, + ] as Job[]; + + const results = await Promise.all(jobs.map((j) => processor.process(j))); + + expect(processDeliverySpy).toHaveBeenCalledTimes(3); + results.forEach((r) => { + expect(r.success).toBe(true); + }); + }); + }); +}); diff --git a/src/notifications/notification.processor.ts b/src/notifications/notification.processor.ts new file mode 100644 index 0000000..7014836 --- /dev/null +++ b/src/notifications/notification.processor.ts @@ -0,0 +1,28 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { Injectable, Logger } from '@nestjs/common'; +import { NotificationService } from './services/notification.service'; + +@Processor('notifications-queue') +@Injectable() +export class NotificationProcessor extends WorkerHost { + private readonly logger = new Logger(NotificationProcessor.name); + + constructor(private readonly notificationService: NotificationService) { + super(); + } + + async process(job: Job): Promise { + this.logger.debug(`Processing notification job ${job.id}: ${job.name}`); + + switch (job.name) { + case 'deliver-notification': { + const { notificationId } = job.data; + await this.notificationService.processDelivery(notificationId); + return { success: true, notificationId }; + } + default: + throw new Error(`Unknown notification job name: ${job.name}`); + } + } +} diff --git a/src/notifications/services/delivery/base-delivery.service.ts b/src/notifications/services/delivery/base-delivery.service.ts new file mode 100644 index 0000000..7e7025b --- /dev/null +++ b/src/notifications/services/delivery/base-delivery.service.ts @@ -0,0 +1,32 @@ +import { Logger } from '@nestjs/common'; +import { NotificationDelivery } from '../../entities/notification-delivery.entity'; +import { DeliveryStatus, DeliveryChannel } from '../../enums/notification-type.enum'; + +export interface DeliveryResult { + success: boolean; + deliveredAt?: Date; + failureReason?: string; + responseData?: Record; +} + +export abstract class BaseDeliveryService { + protected abstract readonly logger: Logger; + + abstract get channel(): DeliveryChannel; + + abstract deliver( + delivery: NotificationDelivery, + ): Promise; + + protected updateDeliveryStatus( + delivery: NotificationDelivery, + status: DeliveryStatus, + extra?: Partial, + ): NotificationDelivery { + Object.assign(delivery, { + status, + ...extra, + }); + return delivery; + } +} diff --git a/src/notifications/services/delivery/delivery-channels.spec.ts b/src/notifications/services/delivery/delivery-channels.spec.ts new file mode 100644 index 0000000..e06815c --- /dev/null +++ b/src/notifications/services/delivery/delivery-channels.spec.ts @@ -0,0 +1,174 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { InAppDeliveryService } from './in-app-delivery.service'; +import { EmailDeliveryService } from './email-delivery.service'; +import { WebhookDeliveryService } from './webhook-delivery.service'; +import { PushDeliveryService } from './push-delivery.service'; +import { SmsDeliveryService } from './sms-delivery.service'; +import { NotificationDelivery } from '../../entities/notification-delivery.entity'; +import { DeliveryChannel, DeliveryStatus } from '../../enums/notification-type.enum'; + +function createMockDelivery(overrides: Partial = {}): NotificationDelivery { + return { + id: 'del-1', + notificationId: 'notif-1', + channel: DeliveryChannel.IN_APP, + status: DeliveryStatus.PENDING, + retryCount: 0, + maxRetries: 5, + destination: null, + failureReason: null, + responseData: null, + queuedAt: null, + sentAt: null, + deliveredAt: null, + lastRetryAt: null, + createdAt: new Date(), + updatedAt: new Date(), + notification: null, + ...overrides, + } as NotificationDelivery; +} + +describe('Delivery Channels', () => { + describe('InAppDeliveryService', () => { + let service: InAppDeliveryService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [InAppDeliveryService], + }).compile(); + service = module.get(InAppDeliveryService); + }); + + it('should deliver in-app notifications', async () => { + const delivery = createMockDelivery(); + const result = await service.deliver(delivery); + expect(result.success).toBe(true); + expect(result.deliveredAt).toBeDefined(); + }); + }); + + describe('EmailDeliveryService', () => { + let service: EmailDeliveryService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + if (key === 'SMTP_HOST') return 'localhost'; + if (key === 'SMTP_PORT') return 587; + if (key === 'SMTP_USER') return ''; + if (key === 'SMTP_PASS') return ''; + if (key === 'SMTP_FROM') return 'noreply@test.com'; + return undefined; + }), + }, + }, + EmailDeliveryService, + ], + }).compile(); + service = module.get(EmailDeliveryService); + }); + + it('should fail when no destination configured', async () => { + const delivery = createMockDelivery({ destination: null }); + const result = await service.deliver(delivery); + expect(result.success).toBe(false); + expect(result.failureReason).toContain('No email destination'); + }); + + it('should log email when SMTP not configured', async () => { + const delivery = createMockDelivery({ + destination: 'test@example.com', + responseData: { subject: 'Test', body: 'Test body' }, + }); + const result = await service.deliver(delivery); + expect(result.success).toBe(true); + }); + }); + + describe('WebhookDeliveryService', () => { + let service: WebhookDeliveryService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [WebhookDeliveryService], + }).compile(); + service = module.get(WebhookDeliveryService); + }); + + it('should fail when no webhook URL configured', async () => { + const delivery = createMockDelivery({ destination: null, channel: DeliveryChannel.WEBHOOK }); + const result = await service.deliver(delivery); + expect(result.success).toBe(false); + expect(result.failureReason).toContain('No webhook URL'); + }); + + it('should handle webhook delivery failure gracefully', async () => { + const delivery = createMockDelivery({ + destination: 'http://invalid-url-that-will-fail.example.com/webhook', + channel: DeliveryChannel.WEBHOOK, + }); + const result = await service.deliver(delivery); + expect(result.success).toBe(false); + }); + }); + + describe('PushDeliveryService', () => { + let service: PushDeliveryService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PushDeliveryService], + }).compile(); + service = module.get(PushDeliveryService); + }); + + it('should fail when no push token configured', async () => { + const delivery = createMockDelivery({ destination: null, channel: DeliveryChannel.PUSH }); + const result = await service.deliver(delivery); + expect(result.success).toBe(false); + expect(result.failureReason).toContain('No push token'); + }); + + it('should succeed with push token (logging mode)', async () => { + const delivery = createMockDelivery({ + destination: 'fcm-token-abc123', + channel: DeliveryChannel.PUSH, + }); + const result = await service.deliver(delivery); + expect(result.success).toBe(true); + }); + }); + + describe('SmsDeliveryService', () => { + let service: SmsDeliveryService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SmsDeliveryService], + }).compile(); + service = module.get(SmsDeliveryService); + }); + + it('should fail when no phone number configured', async () => { + const delivery = createMockDelivery({ destination: null, channel: DeliveryChannel.SMS }); + const result = await service.deliver(delivery); + expect(result.success).toBe(false); + expect(result.failureReason).toContain('No phone number'); + }); + + it('should succeed with phone number (logging mode)', async () => { + const delivery = createMockDelivery({ + destination: '+1234567890', + channel: DeliveryChannel.SMS, + }); + const result = await service.deliver(delivery); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/src/notifications/services/delivery/email-delivery.service.ts b/src/notifications/services/delivery/email-delivery.service.ts new file mode 100644 index 0000000..75f482f --- /dev/null +++ b/src/notifications/services/delivery/email-delivery.service.ts @@ -0,0 +1,64 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { BaseDeliveryService, DeliveryResult } from './base-delivery.service'; +import { DeliveryChannel } from '../../enums/notification-type.enum'; +import { NotificationDelivery } from '../../entities/notification-delivery.entity'; + +@Injectable() +export class EmailDeliveryService extends BaseDeliveryService { + protected readonly logger = new Logger(EmailDeliveryService.name); + readonly channel = DeliveryChannel.EMAIL; + + private readonly transportConfig: { + host: string; + port: number; + user: string; + pass: string; + from: string; + }; + + constructor(configService: ConfigService) { + super(); + this.transportConfig = { + host: configService.get('SMTP_HOST', 'localhost'), + port: configService.get('SMTP_PORT', 587), + user: configService.get('SMTP_USER', ''), + pass: configService.get('SMTP_PASS', ''), + from: configService.get('SMTP_FROM', 'noreply@truthbounty.com'), + }; + } + + async deliver(delivery: NotificationDelivery): Promise { + const destination = delivery.destination; + if (!destination) { + return { success: false, failureReason: 'No email destination configured' }; + } + + try { + this.logger.debug(`Email delivery to ${destination} for notification ${delivery.notificationId}`); + + const smtpConfigured = this.transportConfig.host !== 'localhost' || this.transportConfig.user; + if (!smtpConfigured) { + this.logger.warn('SMTP not configured, logging email instead'); + this.logger.log(`EMAIL TO: ${destination} | Subject: ${delivery.responseData?.subject || 'Notification'} | Body: ${delivery.responseData?.body || ''}`); + return { + success: true, + deliveredAt: new Date(), + responseData: { logged: true, destination }, + }; + } + + return { + success: true, + deliveredAt: new Date(), + responseData: { destination }, + }; + } catch (error) { + this.logger.error(`Email delivery failed to ${destination}: ${error.message}`); + return { + success: false, + failureReason: error.message, + }; + } + } +} diff --git a/src/notifications/services/delivery/in-app-delivery.service.ts b/src/notifications/services/delivery/in-app-delivery.service.ts new file mode 100644 index 0000000..e33f6fb --- /dev/null +++ b/src/notifications/services/delivery/in-app-delivery.service.ts @@ -0,0 +1,27 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { BaseDeliveryService, DeliveryResult } from './base-delivery.service'; +import { DeliveryChannel, DeliveryStatus } from '../../enums/notification-type.enum'; +import { NotificationDelivery } from '../../entities/notification-delivery.entity'; + +@Injectable() +export class InAppDeliveryService extends BaseDeliveryService { + protected readonly logger = new Logger(InAppDeliveryService.name); + readonly channel = DeliveryChannel.IN_APP; + + async deliver(delivery: NotificationDelivery): Promise { + try { + this.logger.debug(`In-app delivery for notification ${delivery.notificationId}`); + return { + success: true, + deliveredAt: new Date(), + responseData: { channel: 'in-app' }, + }; + } catch (error) { + this.logger.error(`In-app delivery failed: ${error.message}`); + return { + success: false, + failureReason: error.message, + }; + } + } +} diff --git a/src/notifications/services/delivery/push-delivery.service.ts b/src/notifications/services/delivery/push-delivery.service.ts new file mode 100644 index 0000000..0f37fa1 --- /dev/null +++ b/src/notifications/services/delivery/push-delivery.service.ts @@ -0,0 +1,39 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { BaseDeliveryService, DeliveryResult } from './base-delivery.service'; +import { DeliveryChannel } from '../../enums/notification-type.enum'; +import { NotificationDelivery } from '../../entities/notification-delivery.entity'; + +@Injectable() +export class PushDeliveryService extends BaseDeliveryService { + protected readonly logger = new Logger(PushDeliveryService.name); + readonly channel = DeliveryChannel.PUSH; + + async deliver(delivery: NotificationDelivery): Promise { + const pushToken = delivery.destination; + if (!pushToken) { + return { success: false, failureReason: 'No push token configured' }; + } + + try { + this.logger.debug(`Push delivery to token ${pushToken.substring(0, 8)}... for notification ${delivery.notificationId}`); + + this.logger.log( + `PUSH NOTIFICATION to ${pushToken.substring(0, 8)}... | ` + + `Title: ${delivery.responseData?.title || ''} | ` + + `Body: ${delivery.responseData?.body || ''}`, + ); + + return { + success: true, + deliveredAt: new Date(), + responseData: { provider: 'fcm-placeholder', tokenPrefix: pushToken.substring(0, 8) }, + }; + } catch (error) { + this.logger.error(`Push delivery failed: ${error.message}`); + return { + success: false, + failureReason: error.message, + }; + } + } +} diff --git a/src/notifications/services/delivery/sms-delivery.service.ts b/src/notifications/services/delivery/sms-delivery.service.ts new file mode 100644 index 0000000..89abfa5 --- /dev/null +++ b/src/notifications/services/delivery/sms-delivery.service.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { BaseDeliveryService, DeliveryResult } from './base-delivery.service'; +import { DeliveryChannel } from '../../enums/notification-type.enum'; +import { NotificationDelivery } from '../../entities/notification-delivery.entity'; + +@Injectable() +export class SmsDeliveryService extends BaseDeliveryService { + protected readonly logger = new Logger(SmsDeliveryService.name); + readonly channel = DeliveryChannel.SMS; + + async deliver(delivery: NotificationDelivery): Promise { + const phoneNumber = delivery.destination; + if (!phoneNumber) { + return { success: false, failureReason: 'No phone number configured' }; + } + + try { + this.logger.debug(`SMS delivery to ${phoneNumber} for notification ${delivery.notificationId}`); + + this.logger.log( + `SMS TO: ${phoneNumber} | Body: ${delivery.responseData?.body || ''}`, + ); + + return { + success: true, + deliveredAt: new Date(), + responseData: { provider: 'twilio-placeholder', phoneNumber }, + }; + } catch (error) { + this.logger.error(`SMS delivery failed to ${phoneNumber}: ${error.message}`); + return { + success: false, + failureReason: error.message, + }; + } + } +} diff --git a/src/notifications/services/delivery/webhook-delivery.service.ts b/src/notifications/services/delivery/webhook-delivery.service.ts new file mode 100644 index 0000000..e52c9c7 --- /dev/null +++ b/src/notifications/services/delivery/webhook-delivery.service.ts @@ -0,0 +1,60 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { BaseDeliveryService, DeliveryResult } from './base-delivery.service'; +import { DeliveryChannel } from '../../enums/notification-type.enum'; +import { NotificationDelivery } from '../../entities/notification-delivery.entity'; + +@Injectable() +export class WebhookDeliveryService extends BaseDeliveryService { + protected readonly logger = new Logger(WebhookDeliveryService.name); + readonly channel = DeliveryChannel.WEBHOOK; + + async deliver(delivery: NotificationDelivery): Promise { + const webhookUrl = delivery.destination; + if (!webhookUrl) { + return { success: false, failureReason: 'No webhook URL configured' }; + } + + try { + this.logger.debug(`Webhook delivery to ${webhookUrl} for notification ${delivery.notificationId}`); + + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'TruthBounty-Notification/1.0', + }, + body: JSON.stringify({ + notificationId: delivery.notificationId, + type: delivery.responseData?.type, + title: delivery.responseData?.title, + body: delivery.responseData?.body, + data: delivery.responseData?.data, + timestamp: new Date().toISOString(), + }), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + const responseText = await response.text().catch(() => 'unknown'); + return { + success: false, + failureReason: `Webhook returned ${response.status}: ${responseText}`, + responseData: { statusCode: response.status, body: responseText }, + }; + } + + const responseBody = await response.text().catch(() => ''); + return { + success: true, + deliveredAt: new Date(), + responseData: { statusCode: response.status, body: responseBody }, + }; + } catch (error) { + this.logger.error(`Webhook delivery failed to ${webhookUrl}: ${error.message}`); + return { + success: false, + failureReason: error.message, + }; + } + } +} diff --git a/src/notifications/services/notification.service.spec.ts b/src/notifications/services/notification.service.spec.ts new file mode 100644 index 0000000..cb6ebbc --- /dev/null +++ b/src/notifications/services/notification.service.spec.ts @@ -0,0 +1,386 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { getQueueToken } from '@nestjs/bullmq'; +import { ConfigService } from '@nestjs/config'; +import { Repository } from 'typeorm'; +import { NotificationService } from './notification.service'; +import { TemplateService } from './template.service'; +import { Notification } from '../entities/notification.entity'; +import { NotificationDelivery } from '../entities/notification-delivery.entity'; +import { UserNotificationPreference } from '../entities/user-notification-preference.entity'; +import { + NotificationType, + DeliveryChannel, + DeliveryStatus, + NotificationPriority, + NotificationFrequency, +} from '../enums/notification-type.enum'; +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'; + +describe('NotificationService', () => { + let service: NotificationService; + let notificationRepo: Repository; + let deliveryRepo: Repository; + let preferencesRepo: Repository; + let queueMock: any; + let templateServiceMock: any; + + const mockNotification = { + id: 'notif-1', + userId: 'user-1', + type: NotificationType.CLAIM_SUBMITTED, + title: 'Test Notification', + body: 'Test body', + status: 'PENDING', + priority: NotificationPriority.NORMAL, + data: {}, + read: false, + deliveries: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockDelivery = { + id: 'delivery-1', + notificationId: 'notif-1', + channel: DeliveryChannel.IN_APP, + status: DeliveryStatus.PENDING, + retryCount: 0, + maxRetries: 5, + queuedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockPreferences = { + id: 'pref-1', + userId: 'user-1', + enabledChannels: ['IN_APP', 'EMAIL'], + frequency: NotificationFrequency.INSTANT, + notificationsEnabled: true, + subscribedCategories: [], + unsubscribedCategories: [], + digestEnabled: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + queueMock = { + add: jest.fn().mockResolvedValue({ id: 'queue-job-1' }), + getWaitingCount: jest.fn().mockResolvedValue(5), + getActiveCount: jest.fn().mockResolvedValue(2), + getDelayedCount: jest.fn().mockResolvedValue(1), + }; + + templateServiceMock = { + render: jest.fn(), + createTemplate: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationService, + { + provide: getRepositoryToken(Notification), + useClass: Repository, + }, + { + provide: getRepositoryToken(NotificationDelivery), + useClass: Repository, + }, + { + provide: getRepositoryToken(UserNotificationPreference), + useClass: Repository, + }, + { + provide: TemplateService, + useValue: templateServiceMock, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + if (key === 'SMTP_HOST') return 'localhost'; + if (key === 'SMTP_PORT') return 587; + if (key === 'SMTP_USER') return ''; + if (key === 'SMTP_PASS') return ''; + if (key === 'SMTP_FROM') return 'noreply@truthbounty.com'; + return undefined; + }), + }, + }, + { + provide: getQueueToken('notifications-queue'), + useValue: queueMock, + }, + InAppDeliveryService, + EmailDeliveryService, + WebhookDeliveryService, + PushDeliveryService, + SmsDeliveryService, + ], + }).compile(); + + service = module.get(NotificationService); + notificationRepo = module.get>(getRepositoryToken(Notification)); + deliveryRepo = module.get>(getRepositoryToken(NotificationDelivery)); + preferencesRepo = module.get>(getRepositoryToken(UserNotificationPreference)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a notification and enqueue delivery', async () => { + const createSpy = jest.spyOn(notificationRepo, 'create').mockReturnValue(mockNotification as any); + const saveSpy = jest.spyOn(notificationRepo, 'save').mockResolvedValue(mockNotification as any); + const findOneSpy = jest.spyOn(notificationRepo, 'findOne').mockResolvedValue({ ...mockNotification, deliveries: [] } as any); + const deliveryCreateSpy = jest.spyOn(deliveryRepo, 'create').mockReturnValue(mockDelivery as any); + const deliverySaveSpy = jest.spyOn(deliveryRepo, 'save').mockResolvedValue(mockDelivery as any); + jest.spyOn(preferencesRepo, 'findOne').mockResolvedValue(mockPreferences as any); + jest.spyOn(preferencesRepo, 'create').mockReturnValue(mockPreferences as any); + + const result = await service.create({ + type: NotificationType.CLAIM_SUBMITTED, + userId: 'user-1', + title: 'Test Notification', + body: 'Test body', + }); + + expect(createSpy).toHaveBeenCalled(); + expect(saveSpy).toHaveBeenCalled(); + expect(deliveryCreateSpy).toHaveBeenCalled(); + expect(deliverySaveSpy).toHaveBeenCalled(); + expect(queueMock.add).toHaveBeenCalledWith( + 'deliver-notification', + { notificationId: 'notif-1' }, + expect.objectContaining({ attempts: 5 }), + ); + expect(result).toBeDefined(); + }); + + it('should respect user preferences when creating notification', async () => { + const userPrefs = { + ...mockPreferences, + notificationsEnabled: false, + }; + jest.spyOn(preferencesRepo, 'findOne').mockResolvedValue(userPrefs as any); + jest.spyOn(preferencesRepo, 'create').mockReturnValue(userPrefs as any); + jest.spyOn(notificationRepo, 'create').mockReturnValue(mockNotification as any); + jest.spyOn(notificationRepo, 'save').mockResolvedValue(mockNotification as any); + jest.spyOn(notificationRepo, 'findOne').mockResolvedValue({ ...mockNotification, deliveries: [] } as any); + + await service.create({ + type: NotificationType.CLAIM_SUBMITTED, + userId: 'user-1', + title: 'Test', + body: 'Test body', + }); + + const deliverySaveSpy = jest.spyOn(deliveryRepo, 'save'); + expect(deliverySaveSpy).not.toHaveBeenCalled(); + }); + }); + + describe('processDelivery', () => { + it('should process pending deliveries and mark them as delivered', async () => { + const notifWithDeliveries = { + ...mockNotification, + deliveries: [{ ...mockDelivery }], + }; + + jest.spyOn(notificationRepo, 'findOne').mockResolvedValue(notifWithDeliveries as any); + const notifSaveSpy = jest.spyOn(notificationRepo, 'save').mockResolvedValue(notifWithDeliveries as any); + const deliverySaveSpy = jest.spyOn(deliveryRepo, 'save').mockResolvedValue(mockDelivery as any); + + await service.processDelivery('notif-1'); + + expect(notifSaveSpy).toHaveBeenCalled(); + expect(deliverySaveSpy).toHaveBeenCalled(); + }); + + it('should mark delivery as failed and retry with backoff', async () => { + const failDelivery = { + ...mockDelivery, + channel: DeliveryChannel.WEBHOOK, + destination: null, + }; + const notifWithDeliveries = { + ...mockNotification, + deliveries: [{ ...failDelivery }], + }; + + jest.spyOn(notificationRepo, 'findOne').mockResolvedValue(notifWithDeliveries as any); + const deliverySaveSpy = jest.spyOn(deliveryRepo, 'save').mockImplementation(async (d) => d as any); + jest.spyOn(notificationRepo, 'save').mockImplementation(async (d) => d as any); + + await service.processDelivery('notif-1'); + + expect(queueMock.add).toHaveBeenCalled(); + expect(deliverySaveSpy).toHaveBeenCalled(); + }); + + it('should handle notification not found gracefully', async () => { + jest.spyOn(notificationRepo, 'findOne').mockResolvedValue(null); + await expect(service.processDelivery('nonexistent')).resolves.not.toThrow(); + }); + }); + + describe('user preferences', () => { + it('should get or create preferences', async () => { + jest.spyOn(preferencesRepo, 'findOne').mockResolvedValue(null); + const createSpy = jest.spyOn(preferencesRepo, 'create').mockReturnValue(mockPreferences as any); + const saveSpy = jest.spyOn(preferencesRepo, 'save').mockResolvedValue(mockPreferences as any); + + const result = await service.getOrCreatePreferences('new-user'); + + expect(createSpy).toHaveBeenCalledWith(expect.objectContaining({ userId: 'new-user' })); + expect(saveSpy).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should update preferences', async () => { + jest.spyOn(preferencesRepo, 'findOne').mockResolvedValue(mockPreferences as any); + const saveSpy = jest.spyOn(preferencesRepo, 'save').mockImplementation(async (p) => p as any); + + const result = await service.updatePreferences('user-1', { + frequency: NotificationFrequency.DAILY, + emailAddress: 'test@example.com', + }); + + expect(saveSpy).toHaveBeenCalled(); + expect(result.frequency).toBe(NotificationFrequency.DAILY); + expect(result.emailAddress).toBe('test@example.com'); + }); + + it('should enforce quiet hours', async () => { + const quietPrefs = { + ...mockPreferences, + quietHoursStart: ['22:00'], + quietHoursEnd: ['06:00'], + }; + + jest.spyOn(preferencesRepo, 'findOne').mockResolvedValue(quietPrefs as any); + jest.spyOn(preferencesRepo, 'save').mockImplementation(async (p) => p as any); + + const result = await service.getOrCreatePreferences('user-1'); + expect(result.quietHoursStart).toEqual(['22:00']); + expect(result.quietHoursEnd).toEqual(['06:00']); + }); + }); + + describe('markAsRead', () => { + it('should mark notification as read', async () => { + jest.spyOn(notificationRepo, 'findOne').mockResolvedValue(mockNotification as any); + const saveSpy = jest.spyOn(notificationRepo, 'save').mockResolvedValue({ ...mockNotification, read: true, readAt: new Date() } as any); + + const result = await service.markAsRead('notif-1', 'user-1'); + + expect(saveSpy).toHaveBeenCalled(); + expect(result.read).toBe(true); + }); + + it('should throw when notification not found', async () => { + jest.spyOn(notificationRepo, 'findOne').mockResolvedValue(null); + await expect(service.markAsRead('nonexistent', 'user-1')).rejects.toThrow('Notification not found'); + }); + }); + + describe('markAllAsRead', () => { + it('should mark all unread notifications as read', async () => { + jest.spyOn(notificationRepo, 'update').mockResolvedValue({ affected: 5, raw: {}, generatedMaps: [] } as any); + + const count = await service.markAllAsRead('user-1'); + + expect(count).toBe(5); + }); + }); + + describe('scheduled notifications', () => { + it('should schedule a notification with a future date', async () => { + const createSpy = jest.spyOn(service, 'create').mockResolvedValue(mockNotification as any); + const futureDate = new Date(Date.now() + 86400000).toISOString(); + + const result = await service.scheduleNotification({ + type: NotificationType.SYSTEM_MAINTENANCE, + userId: 'user-1', + title: 'Scheduled Maintenance', + body: 'System will be down', + scheduledAt: futureDate, + }); + + expect(createSpy).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + }); + + describe('cancelScheduled', () => { + it('should cancel a pending scheduled notification', async () => { + const pendingNotif = { ...mockNotification, status: 'PENDING' }; + jest.spyOn(notificationRepo, 'findOne').mockResolvedValue(pendingNotif as any); + const saveSpy = jest.spyOn(notificationRepo, 'save').mockImplementation(async (n) => n as any); + jest.spyOn(deliveryRepo, 'update').mockResolvedValue({ affected: 1, raw: {}, generatedMaps: [] } as any); + + const result = await service.cancelScheduled('notif-1', 'user-1'); + + expect(saveSpy).toHaveBeenCalled(); + expect(result.status).toBe('CANCELLED'); + }); + + it('should throw when notification is not found', async () => { + jest.spyOn(notificationRepo, 'findOne').mockResolvedValue(null); + await expect(service.cancelScheduled('nonexistent', 'user-1')).rejects.toThrow('Notification not found'); + }); + }); + + describe('getUserNotifications', () => { + it('should return paginated notifications with total count', async () => { + jest.spyOn(notificationRepo, 'findAndCount').mockResolvedValue([[mockNotification], 1] as any); + + const result = await service.getUserNotifications('user-1', {}); + + expect(result.notifications).toHaveLength(1); + expect(result.total).toBe(1); + }); + }); + + describe('getUnreadCount', () => { + it('should return count of unread notifications', async () => { + jest.spyOn(notificationRepo, 'count').mockResolvedValue(3); + + const count = await service.getUnreadCount('user-1'); + + expect(count).toBe(3); + }); + }); + + describe('getDeliveryHistory', () => { + it('should return delivery records for a notification', async () => { + jest.spyOn(deliveryRepo, 'find').mockResolvedValue([mockDelivery] as any); + + const result = await service.getDeliveryHistory('notif-1'); + + expect(result).toHaveLength(1); + }); + }); + + describe('getMetrics', () => { + it('should return current metrics', async () => { + jest.spyOn(deliveryRepo, 'count').mockResolvedValue(3); + + const metrics = await service.getMetrics(); + + expect(metrics).toHaveProperty('queued'); + expect(metrics).toHaveProperty('delivered'); + expect(metrics).toHaveProperty('failed'); + expect(metrics).toHaveProperty('queueDepth'); + expect(metrics.queueDepth).toBe(8); + }); + }); +}); diff --git a/src/notifications/services/notification.service.ts b/src/notifications/services/notification.service.ts new file mode 100644 index 0000000..e52ae97 --- /dev/null +++ b/src/notifications/services/notification.service.ts @@ -0,0 +1,457 @@ +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/services/template.service.spec.ts b/src/notifications/services/template.service.spec.ts new file mode 100644 index 0000000..6ebc550 --- /dev/null +++ b/src/notifications/services/template.service.spec.ts @@ -0,0 +1,139 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TemplateService } from './template.service'; +import { NotificationTemplate } from '../entities/notification-template.entity'; +import { NotificationType } from '../enums/notification-type.enum'; + +describe('TemplateService', () => { + let service: TemplateService; + let templateRepo: Repository; + + const mockTemplate = { + id: 'tpl-1', + name: 'claim-submitted', + type: NotificationType.CLAIM_SUBMITTED, + subjectTemplate: 'Claim Submitted: {{claimTitle}}', + bodyTemplate: 'Your claim "{{claimTitle}}" has been submitted.', + variables: ['claimTitle', 'claimId'], + locale: 'en', + version: 1, + active: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TemplateService, + { + provide: getRepositoryToken(NotificationTemplate), + useClass: Repository, + }, + ], + }).compile(); + + service = module.get(TemplateService); + templateRepo = module.get>(getRepositoryToken(NotificationTemplate)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('render', () => { + it('should render a template with variables', async () => { + jest.spyOn(templateRepo, 'findOne').mockResolvedValue(mockTemplate as any); + + const result = await service.render('claim-submitted', { + claimTitle: 'Test Claim', + claimId: 'claim-123', + }); + + expect(result.subject).toBe('Claim Submitted: Test Claim'); + expect(result.body).toBe('Your claim "Test Claim" has been submitted.'); + }); + + it('should fall back to default locale when template not found', async () => { + const enTemplate = { ...mockTemplate, locale: 'en' }; + jest.spyOn(templateRepo, 'findOne') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(enTemplate as any); + + const result = await service.render('claim-submitted', { + claimTitle: 'Test', + }, 'fr'); + + expect(result.subject).toBe('Claim Submitted: Test'); + }); + + it('should throw when template not found in any locale', async () => { + jest.spyOn(templateRepo, 'findOne').mockResolvedValue(null); + + await expect(service.render('nonexistent', {})).rejects.toThrow('Template nonexistent not found'); + }); + }); + + describe('renderByType', () => { + it('should render template by notification type', async () => { + jest.spyOn(templateRepo, 'findOne').mockResolvedValue(mockTemplate as any); + + const result = await service.renderByType(NotificationType.CLAIM_SUBMITTED, { + claimTitle: 'Test Claim', + }); + + expect(result.subject).toBe('Claim Submitted: Test Claim'); + }); + }); + + describe('createTemplate', () => { + it('should create a new template', async () => { + const createSpy = jest.spyOn(templateRepo, 'create').mockReturnValue(mockTemplate as any); + const saveSpy = jest.spyOn(templateRepo, 'save').mockResolvedValue(mockTemplate as any); + + const result = await service.createTemplate(mockTemplate); + + expect(createSpy).toHaveBeenCalledWith(mockTemplate); + expect(saveSpy).toHaveBeenCalled(); + expect(result).toEqual(mockTemplate); + }); + }); + + describe('findAll', () => { + it('should return all templates ordered by name', async () => { + jest.spyOn(templateRepo, 'find').mockResolvedValue([mockTemplate] as any); + + const result = await service.findAll(); + + expect(result).toHaveLength(1); + }); + }); + + describe('private substitute', () => { + it('should replace {{variables}} in template strings', async () => { + const template = { + ...mockTemplate, + subjectTemplate: 'Hello {{name}}, your {{item}} is ready', + bodyTemplate: 'Plain text without variables', + }; + jest.spyOn(templateRepo, 'findOne').mockResolvedValue(template as any); + + const result = await service.render('claim-submitted', { + name: 'Alice', + item: 'order', + }); + + expect(result.subject).toBe('Hello Alice, your order is ready'); + expect(result.body).toBe('Plain text without variables'); + }); + + it('should leave unmatched variables as-is', async () => { + jest.spyOn(templateRepo, 'findOne').mockResolvedValue(mockTemplate as any); + + const result = await service.render('claim-submitted', {}); + + expect(result.subject).toBe('Claim Submitted: {{claimTitle}}'); + }); + }); +}); diff --git a/src/notifications/services/template.service.ts b/src/notifications/services/template.service.ts new file mode 100644 index 0000000..b8842fe --- /dev/null +++ b/src/notifications/services/template.service.ts @@ -0,0 +1,93 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationTemplate } from '../entities/notification-template.entity'; +import { NotificationType } from '../enums/notification-type.enum'; + +@Injectable() +export class TemplateService { + private readonly logger = new Logger(TemplateService.name); + + constructor( + @InjectRepository(NotificationTemplate) + private readonly templateRepo: Repository, + ) {} + + async render( + templateName: string, + variables: Record, + locale = 'en', + ): Promise<{ subject: string; body: string; html?: string; markdown?: string }> { + const template = await this.templateRepo.findOne({ + where: { name: templateName, active: true, locale }, + }); + + if (!template) { + this.logger.warn(`Template ${templateName} not found for locale ${locale}, trying default locale`); + const fallback = await this.templateRepo.findOne({ + where: { name: templateName, active: true, locale: 'en' }, + }); + if (!fallback) { + throw new Error(`Template ${templateName} not found`); + } + return this.renderTemplate(fallback, variables); + } + + return this.renderTemplate(template, variables); + } + + async renderByType( + type: NotificationType, + variables: Record, + locale = 'en', + ): Promise<{ subject: string; body: string; html?: string; markdown?: string }> { + const template = await this.templateRepo.findOne({ + where: { type, active: true, locale }, + }); + + if (!template) { + const fallback = await this.templateRepo.findOne({ + where: { type, active: true, locale: 'en' }, + }); + if (!fallback) { + throw new Error(`No active template found for type ${type}`); + } + return this.renderTemplate(fallback, variables); + } + + return this.renderTemplate(template, variables); + } + + private renderTemplate( + template: NotificationTemplate, + variables: Record, + ): { subject: string; body: string; html?: string; markdown?: string } { + return { + subject: this.substitute(template.subjectTemplate, variables), + body: this.substitute(template.bodyTemplate, variables), + html: template.htmlTemplate ? this.substitute(template.htmlTemplate, variables) : undefined, + markdown: template.markdownTemplate ? this.substitute(template.markdownTemplate, variables) : undefined, + }; + } + + private substitute(template: string, variables: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] || `{{${key}}}`); + } + + async createTemplate(data: Partial): Promise { + return this.templateRepo.save(this.templateRepo.create(data)); + } + + async updateTemplate(id: string, data: Partial): Promise { + await this.templateRepo.update(id, data); + return this.templateRepo.findOneOrFail({ where: { id } }); + } + + async findAll(): Promise { + return this.templateRepo.find({ order: { name: 'ASC' } }); + } + + async findOne(id: string): Promise { + return this.templateRepo.findOneOrFail({ where: { id } }); + } +}