diff --git a/src/auth/decorators/current-user.decorator.ts b/src/auth/decorators/current-user.decorator.ts index 461622c8..4e610b76 100644 --- a/src/auth/decorators/current-user.decorator.ts +++ b/src/auth/decorators/current-user.decorator.ts @@ -1,6 +1,8 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -export const CurrentUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => { +export const CurrentUser = createParamDecorator((data: string, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); - return request.user; + const user = request.user; + + return data ? user?.[data] : user; }); diff --git a/src/notifications/dto/notification.dto.ts b/src/notifications/dto/notification.dto.ts index e69de29b..00c108fd 100644 --- a/src/notifications/dto/notification.dto.ts +++ b/src/notifications/dto/notification.dto.ts @@ -0,0 +1,79 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + IsBoolean, + IsObject, +} from 'class-validator'; +import { NotificationType, NotificationPriority } from '../entities/notification.entity'; + +export class CreateNotificationDto { + @ApiProperty({ description: 'The ID of the user the notification is for' }) + @IsUUID() + @IsNotEmpty() + userId: string; + + @ApiProperty({ description: 'The title of the notification' }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ description: 'The content of the notification' }) + @IsString() + @IsNotEmpty() + content: string; + + @ApiProperty({ enum: NotificationType, description: 'The type of the notification' }) + @IsEnum(NotificationType) + @IsOptional() + type?: NotificationType; + + @ApiProperty({ enum: NotificationPriority, description: 'The priority of the notification' }) + @IsEnum(NotificationPriority) + @IsOptional() + priority?: NotificationPriority; + + @ApiPropertyOptional({ description: 'Additional metadata for the notification' }) + @IsObject() + @IsOptional() + metadata?: Record; +} + +export class UpdateNotificationDto { + @ApiPropertyOptional({ description: 'Mark as read or unread' }) + @IsBoolean() + @IsOptional() + isRead?: boolean; +} + +export class NotificationResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + userId: string; + + @ApiProperty() + title: string; + + @ApiProperty() + content: string; + + @ApiProperty({ enum: NotificationType }) + type: NotificationType; + + @ApiProperty({ enum: NotificationPriority }) + priority: NotificationPriority; + + @ApiProperty() + isRead: boolean; + + @ApiPropertyOptional() + readAt?: Date; + + @ApiProperty() + createdAt: Date; +} diff --git a/src/notifications/entities/notification-preferences.entity.ts b/src/notifications/entities/notification-preferences.entity.ts index e69de29b..a56ddfdc 100644 --- a/src/notifications/entities/notification-preferences.entity.ts +++ b/src/notifications/entities/notification-preferences.entity.ts @@ -0,0 +1,50 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('notification_preferences') +export class NotificationPreferences { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @OneToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn() + user: User; + + @Column({ default: true }) + emailEnabled: boolean; + + @Column({ default: true }) + pushEnabled: boolean; + + @Column({ default: true }) + inAppEnabled: boolean; + + @Column({ default: false }) + smsEnabled: boolean; + + @Column({ type: 'jsonb', nullable: true }) + topicSubscriptions: Record; + + @Column({ type: 'varchar', default: '09:00' }) + quietTimeStart: string; + + @Column({ type: 'varchar', default: '21:00' }) + quietTimeEnd: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/notifications/entities/notification.entity.ts b/src/notifications/entities/notification.entity.ts index e69de29b..9fe558fd 100644 --- a/src/notifications/entities/notification.entity.ts +++ b/src/notifications/entities/notification.entity.ts @@ -0,0 +1,73 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +export enum NotificationType { + EMAIL = 'email', + PUSH = 'push', + IN_APP = 'in_app', + SMS = 'sms', +} + +export enum NotificationPriority { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', + URGENT = 'urgent', +} + +@Entity('notifications') +export class Notification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + @Index() + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user: User; + + @Column() + title: string; + + @Column('text') + content: string; + + @Column({ + type: 'enum', + enum: NotificationType, + default: NotificationType.IN_APP, + }) + type: NotificationType; + + @Column({ + type: 'enum', + enum: NotificationPriority, + default: NotificationPriority.MEDIUM, + }) + priority: NotificationPriority; + + @Column({ default: false }) + isRead: boolean; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @Column({ nullable: true }) + readAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + @Index() + updatedAt: Date; +} diff --git a/src/notifications/notification-templates.service.ts b/src/notifications/notification-templates.service.ts index e69de29b..1a19225e 100644 --- a/src/notifications/notification-templates.service.ts +++ b/src/notifications/notification-templates.service.ts @@ -0,0 +1,67 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { NotificationType } from './entities/notification.entity'; + +export interface NotificationTemplate { + title: string; + content: string; +} + +@Injectable() +export class NotificationTemplatesService { + private readonly logger = new Logger(NotificationTemplatesService.name); + private readonly templates: Map NotificationTemplate> = new Map(); + + constructor() { + this.registerTemplates(); + } + + private registerTemplates() { + this.templates.set('COURSE_ENROLLMENT', (data: any) => ({ + title: 'Course Enrollment', + content: `Welcome to the course ${data.courseName}! You have been successfully enrolled.`, + })); + + this.templates.set('PAYMENT_RECEIVED', (data: any) => ({ + title: 'Payment Confirmed', + content: `A payment of ${data.amount} ${data.currency} for the course ${data.courseName} was successfully processed.`, + })); + + this.templates.set('NEW_MESSAGE', (data: any) => ({ + title: 'New Message', + content: `You have received a new message from ${data.senderName}.`, + })); + + this.templates.set('COURSE_REPLY', (data: any) => ({ + title: 'Course Discussion Reply', + content: `${data.senderName} replied to your comment in ${data.courseName}.`, + })); + } + + renderTemplate(type: string, data: any): NotificationTemplate { + const templateFn = this.templates.get(type); + if (!templateFn) { + this.logger.warn(`Template callback not found for type: ${type}`); + return { + title: 'Notification', + content: JSON.stringify(data), + }; + } + + return templateFn(data); + } + + formatForType(n: NotificationTemplate, type: NotificationType): string { + switch (type) { + case NotificationType.EMAIL: + return `

${n.title}

${n.content}

`; + case NotificationType.PUSH: + return n.content; + case NotificationType.IN_APP: + return n.content; + case NotificationType.SMS: + return `${n.title}: ${n.content}`; + default: + return n.content; + } + } +} diff --git a/src/notifications/notifications.controller.ts b/src/notifications/notifications.controller.ts index e69de29b..7581abaa 100644 --- a/src/notifications/notifications.controller.ts +++ b/src/notifications/notifications.controller.ts @@ -0,0 +1,81 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + UseGuards, + Query, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { NotificationsService } from './notifications.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { NotificationResponseDto, UpdateNotificationDto } from './dto/notification.dto'; +import { NotificationPreferences } from './entities/notification-preferences.entity'; + +@ApiTags('Notifications') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('notifications') +export class NotificationsController { + constructor(private readonly notificationsService: NotificationsService) {} + + @Get() + @ApiOperation({ summary: 'Get all notifications for current user' }) + @ApiResponse({ status: 200, type: [NotificationResponseDto] }) + async getMyNotifications( + @CurrentUser('id') userId: string, + @Query('isRead') isRead?: boolean, + @Query('limit') limit?: number, + @Query('offset') offset?: number, + ) { + const [data, total] = await this.notificationsService.findAllForUser(userId, { + isRead, + limit, + offset, + }); + return { data, total }; + } + + @Patch(':id/read') + @ApiOperation({ summary: 'Mark a notification as read' }) + @ApiParam({ name: 'id', description: 'Notification ID' }) + async markAsRead(@Param('id') id: string, @CurrentUser('id') userId: string) { + return this.notificationsService.markAsRead(id, userId); + } + + @Patch('read-all') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Mark all notifications as read' }) + async markAllAsRead(@CurrentUser('id') userId: string) { + await this.notificationsService.markAllAsRead(userId); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete a notification' }) + @ApiParam({ name: 'id', description: 'Notification ID' }) + async deleteNotification(@Param('id') id: string, @CurrentUser('id') userId: string) { + await this.notificationsService.remove(id, userId); + } + + @Get('preferences') + @ApiOperation({ summary: 'Get current user notification preferences' }) + async getPreferences(@CurrentUser('id') userId: string) { + return this.notificationsService.getPreferences(userId); + } + + @Patch('preferences') + @ApiOperation({ summary: 'Update notification preferences' }) + async updatePreferences( + @CurrentUser('id') userId: string, + @Body() preferencesDto: Partial, + ) { + return this.notificationsService.updatePreferences(userId, preferencesDto); + } +} diff --git a/src/notifications/notifications.gateway.ts b/src/notifications/notifications.gateway.ts index e69de29b..ec89ce20 100644 --- a/src/notifications/notifications.gateway.ts +++ b/src/notifications/notifications.gateway.ts @@ -0,0 +1,92 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + OnGatewayConnection, + OnGatewayDisconnect, + ConnectedSocket, + MessageBody, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { UseGuards, Logger } from '@nestjs/common'; +import { WsJwtAuthGuard } from '../auth/guards/ws-jwt-auth.guard'; +import { Notification } from './entities/notification.entity'; + +@WebSocketGateway({ + cors: { + origin: '*', + }, + namespace: 'notifications', +}) +export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server; + + private readonly logger = new Logger(NotificationsGateway.name); + private userSockets: Map> = new Map(); + + async handleConnection(client: Socket) { + this.logger.log(`Client connected: ${client.id}`); + } + + handleDisconnect(client: Socket) { + this.logger.log(`Client disconnected: ${client.id}`); + this.removeSocketId(client.id); + } + + @UseGuards(WsJwtAuthGuard) + @SubscribeMessage('subscribe') + async handleSubscribe( + @ConnectedSocket() client: Socket, + @MessageBody() data: { userId: string }, + ) { + const user = (client as any).user; + const userId = user?.id || data.userId; + + if (!userId) { + this.logger.warn(`User ID not found for client: ${client.id}`); + return; + } + + this.addSocketId(userId, client.id); + client.join(`user:${userId}`); + this.logger.log(`User ${userId} subscribed to notifications via socket ${client.id}`); + + return { status: 'subscribed', userId }; + } + + /** + * Send notification to a specific user in real-time + */ + async sendToUser(userId: string, notification: Notification) { + this.server.to(`user:${userId}`).emit('notification', notification); + this.logger.debug(`Notification sent to user:${userId}`); + } + + /** + * Broadcast notification to all users + */ + async broadcast(notification: Partial) { + this.server.emit('broadcast_notification', notification); + this.logger.debug('Broadcast notification sent'); + } + + private addSocketId(userId: string, socketId: string) { + if (!this.userSockets.has(userId)) { + this.userSockets.set(userId, new Set()); + } + this.userSockets.get(userId)?.add(socketId); + } + + private removeSocketId(socketId: string) { + for (const [userId, sockets] of this.userSockets.entries()) { + if (sockets.has(socketId)) { + sockets.delete(socketId); + if (sockets.size === 0) { + this.userSockets.delete(userId); + } + break; + } + } + } +} diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts index 7fc2bae2..db21aa29 100644 --- a/src/notifications/notifications.module.ts +++ b/src/notifications/notifications.module.ts @@ -1,3 +1,27 @@ +import { Module, Global } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { NotificationsService } from './notifications.service'; +import { NotificationsController } from './notifications.controller'; +import { NotificationsGateway } from './notifications.gateway'; +import { NotificationTemplatesService } from './notification-templates.service'; +import { PreferencesService } from './preferences/preferences.service'; +import { Notification } from './entities/notification.entity'; +import { NotificationPreferences } from './entities/notification-preferences.entity'; + +@Global() +@Module({ + imports: [ + TypeOrmModule.forFeature([Notification, NotificationPreferences]), + ], + controllers: [NotificationsController], + providers: [ + NotificationsService, + NotificationsGateway, + NotificationTemplatesService, + PreferencesService, + ], + exports: [NotificationsService, PreferencesService], +}) import { Module } from '@nestjs/common'; @Module({}) diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index e69de29b..de3dcd98 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -0,0 +1,196 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; +import { Notification, NotificationType, NotificationPriority } from './entities/notification.entity'; +import { NotificationPreferences } from './entities/notification-preferences.entity'; +import { CreateNotificationDto, UpdateNotificationDto } from './dto/notification.dto'; +import { NotificationsGateway } from './notifications.gateway'; +import { NotificationTemplatesService } from './notification-templates.service'; +import { PreferencesService } from './preferences/preferences.service'; + +@Injectable() +export class NotificationsService { + private readonly logger = new Logger(NotificationsService.name); + + constructor( + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + private readonly gateway: NotificationsGateway, + private readonly templatesService: NotificationTemplatesService, + private readonly preferencesService: PreferencesService, + private readonly eventEmitter: EventEmitter2, + ) {} + + /** + * Create and send a notification + */ + async create(createNotificationDto: CreateNotificationDto): Promise { + const { userId, title, content, type, priority, metadata } = createNotificationDto; + + // Check user preferences + const preferences = await this.preferencesService.getPreferences(userId); + const shouldSend = this.shouldSendNotification(type || NotificationType.IN_APP, preferences); + + if (!shouldSend) { + this.logger.debug(`Notification skipped for user ${userId} based on preferences`); + } + + // Save to database + const notification = this.notificationRepository.create({ + userId, + title, + content, + type: type || NotificationType.IN_APP, + priority: priority || NotificationPriority.MEDIUM, + metadata, + }); + + const savedNotification = await this.notificationRepository.save(notification); + + // Send via appropriate channel if enabled + if (shouldSend) { + await this.sendNotification(savedNotification); + } + + return savedNotification; + } + + /** + * Send notification via the specified channel + */ + private async sendNotification(notification: Notification): Promise { + try { + // 1. Always try internal push via WebSocket if it's IN_APP or PUSH + if ( + notification.type === NotificationType.IN_APP || + notification.type === NotificationType.PUSH + ) { + await this.gateway.sendToUser(notification.userId, notification); + } + + // 2. Handle EMAIL type + if (notification.type === NotificationType.EMAIL) { + await this.sendEmailNotification(notification); + } + + // 3. Handle PUSH type (external push e.g. FCM/WebPush - placeholder) + if (notification.type === NotificationType.PUSH) { + await this.sendExternalPushNotification(notification); + } + } catch (error) { + this.logger.error(`Failed to send notification ${notification.id}:`, error); + } + } + + private async sendEmailNotification(notification: Notification): Promise { + this.logger.log(`Sending email notification to user ${notification.userId}: ${notification.title}`); + // Here you would integrate with a MailerService + // Example: await this.mailerService.sendMail({ ... }); + } + + private async sendExternalPushNotification(notification: Notification): Promise { + this.logger.log(`Sending external push notification to user ${notification.userId}: ${notification.title}`); + // Here you would integrate with FCM, OneSignal, etc. + } + + /** + * Get all notifications for a user + */ + async findAllForUser(userId: string, options: { isRead?: boolean; limit?: number; offset?: number } = {}): Promise<[Notification[], number]> { + const query = this.notificationRepository.createQueryBuilder('notification') + .where('notification.userId = :userId', { userId }); + + if (options.isRead !== undefined) { + query.andWhere('notification.isRead = :isRead', { isRead: options.isRead }); + } + + query.orderBy('notification.createdAt', 'DESC') + .take(options.limit || 20) + .skip(options.offset || 0); + + return query.getManyAndCount(); + } + + /** + * Mark a notification as read + */ + async markAsRead(id: string, userId: string): Promise { + const notification = await this.notificationRepository.findOne({ where: { id, userId } }); + if (!notification) { + throw new NotFoundException(`Notification with ID ${id} not found`); + } + + notification.isRead = true; + notification.readAt = new Date(); + return this.notificationRepository.save(notification); + } + + /** + * Mark all notifications as read for a user + */ + async markAllAsRead(userId: string): Promise { + await this.notificationRepository.update( + { userId, isRead: false }, + { isRead: true, readAt: new Date() } + ); + } + + /** + * Delete a notification + */ + async remove(id: string, userId: string): Promise { + const result = await this.notificationRepository.delete({ id, userId }); + if (result.affected === 0) { + throw new NotFoundException(`Notification with ID ${id} not found`); + } + } + + /** + * Update notification preferences + */ + async updatePreferences(userId: string, updateDto: Partial): Promise { + return this.preferencesService.updatePreferences(userId, updateDto); + } + + /** + * Get user preferences + */ + async getPreferences(userId: string): Promise { + return this.preferencesService.getPreferences(userId); + } + + private shouldSendNotification(type: NotificationType, preferences: NotificationPreferences): boolean { + switch (type) { + case NotificationType.EMAIL: return preferences.emailEnabled; + case NotificationType.PUSH: return preferences.pushEnabled; + case NotificationType.IN_APP: return preferences.inAppEnabled; + case NotificationType.SMS: return preferences.smsEnabled; + default: return true; + } + } + + /** + * Event listener for system-wide notifications + */ + @OnEvent('notification.send') + async handleSendNotification(payload: CreateNotificationDto) { + await this.create(payload); + } + + /** + * Event listener for specific templates + */ + @OnEvent('notification.template.send') + async handleSendTemplateNotification(payload: { userId: string; templateType: string; data: any; type?: NotificationType }) { + const template = this.templatesService.renderTemplate(payload.templateType, payload.data); + + await this.create({ + userId: payload.userId, + title: template.title, + content: template.content, + type: payload.type || NotificationType.IN_APP, + priority: NotificationPriority.MEDIUM, + }); + } +} diff --git a/src/notifications/preferences/preferences.service.ts b/src/notifications/preferences/preferences.service.ts index e69de29b..a52ee17a 100644 --- a/src/notifications/preferences/preferences.service.ts +++ b/src/notifications/preferences/preferences.service.ts @@ -0,0 +1,53 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationPreferences } from '../entities/notification-preferences.entity'; + +@Injectable() +export class PreferencesService { + private readonly logger = new Logger(PreferencesService.name); + + constructor( + @InjectRepository(NotificationPreferences) + private readonly preferencesRepository: Repository, + ) {} + + /** + * Get user preferences or create default if not exists + */ + async getPreferences(userId: string): Promise { + let preferences = await this.preferencesRepository.findOne({ where: { userId } }); + if (!preferences) { + this.logger.debug(`Creating default preferences for user ${userId}`); + preferences = this.preferencesRepository.create({ userId }); + preferences = await this.preferencesRepository.save(preferences); + } + return preferences; + } + + /** + * Update user preferences + */ + async updatePreferences(userId: string, updateDto: Partial): Promise { + const preferences = await this.getPreferences(userId); + Object.assign(preferences, updateDto); + return this.preferencesRepository.save(preferences); + } + + /** + * Check if a specific channel is enabled for a user + */ + async isChannelEnabled(userId: string, channel: 'emailEnabled' | 'pushEnabled' | 'inAppEnabled' | 'smsEnabled'): Promise { + const preferences = await this.getPreferences(userId); + return !!preferences[channel]; + } + + /** + * Toggle a specific channel for a user + */ + async toggleChannel(userId: string, channel: 'emailEnabled' | 'pushEnabled' | 'inAppEnabled' | 'smsEnabled'): Promise { + const preferences = await this.getPreferences(userId); + preferences[channel] = !preferences[channel]; + await this.preferencesRepository.save(preferences); + } +}