# ET-NOTIF-BACKEND: Servicios y API REST ## Identificacion | Campo | Valor | |-------|-------| | **ID** | ET-NOTIF-BACKEND | | **Modulo** | MGN-008 Notifications | | **Version** | 1.0 | | **Estado** | En Diseno | | **Framework** | NestJS | | **Autor** | Requirements-Analyst | | **Fecha** | 2025-12-05 | --- ## Estructura de Archivos ``` apps/backend/src/modules/notifications/ ├── notifications.module.ts ├── controllers/ │ ├── notifications.controller.ts │ ├── email-templates.controller.ts │ ├── push.controller.ts │ └── preferences.controller.ts ├── services/ │ ├── notifications.service.ts │ ├── email.service.ts │ ├── email-templates.service.ts │ ├── push.service.ts │ └── preferences.service.ts ├── gateways/ │ └── notifications.gateway.ts ├── entities/ │ ├── notification.entity.ts │ ├── email-template.entity.ts │ ├── email-job.entity.ts │ ├── push-subscription.entity.ts │ ├── push-job.entity.ts │ └── notification-preference.entity.ts ├── dto/ │ ├── send-notification.dto.ts │ ├── send-email.dto.ts │ ├── create-template.dto.ts │ ├── subscribe-push.dto.ts │ └── update-preferences.dto.ts ├── processors/ │ ├── email.processor.ts │ └── push.processor.ts └── templates/ └── base.hbs ``` --- ## Entidades ### Notification Entity ```typescript @Entity('notifications', { schema: 'core_notifications' }) export class Notification { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'tenant_id', type: 'uuid' }) tenantId: string; @Column({ name: 'user_id', type: 'uuid' }) userId: string; @Column({ length: 50 }) type: NotificationType; @Column({ length: 255 }) title: string; @Column({ type: 'text' }) body: string; @Column({ type: 'jsonb', default: {} }) data: Record; @Column({ name: 'action_url', length: 500, nullable: true }) actionUrl: string; @Column({ name: 'action_label', length: 100, nullable: true }) actionLabel: string; @Column({ length: 50, nullable: true }) icon: string; @Column({ type: 'varchar', array: true, default: ['in_app'] }) channels: NotificationChannel[]; @Column({ name: 'is_read', default: false }) isRead: boolean; @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) readAt: Date; @Column({ name: 'is_archived', default: false }) isArchived: boolean; @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) expiresAt: Date; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @ManyToOne(() => User) @JoinColumn({ name: 'user_id' }) user: User; } export type NotificationChannel = 'in_app' | 'email' | 'push'; export type NotificationType = | 'security.login_new_device' | 'security.password_changed' | 'user.mention' | 'user.assignment' | 'task.assigned' | 'task.completed' | 'document.shared' | 'payment.received' | 'report.ready'; ``` ### EmailTemplate Entity ```typescript @Entity('email_templates', { schema: 'core_notifications' }) export class EmailTemplate { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) tenantId: string; @Column({ length: 100, unique: true }) key: string; @Column({ length: 255 }) name: string; @Column({ length: 255 }) subject: string; @Column({ name: 'body_html', type: 'text' }) bodyHtml: string; @Column({ name: 'body_text', type: 'text', nullable: true }) bodyText: string; @Column({ type: 'jsonb', default: [] }) variables: TemplateVariable[]; @Column({ length: 50, default: 'transactional' }) category: EmailCategory; @Column({ name: 'is_active', default: true }) isActive: boolean; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` ### PushSubscription Entity ```typescript @Entity('push_subscriptions', { schema: 'core_notifications' }) export class PushSubscription { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'user_id', type: 'uuid' }) userId: string; @Column({ length: 500, unique: true }) endpoint: string; @Column({ length: 255 }) p256dh: string; @Column({ length: 255 }) auth: string; @Column({ name: 'device_type', length: 20 }) deviceType: 'web' | 'android' | 'ios'; @Column({ name: 'device_name', length: 100, nullable: true }) deviceName: string; @Column({ length: 50, nullable: true }) browser: string; @Column({ name: 'is_active', default: true }) isActive: boolean; @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) lastUsedAt: Date; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; } ``` --- ## Servicios ### NotificationsService ```typescript @Injectable() export class NotificationsService { constructor( @InjectRepository(Notification) private readonly repo: Repository, private readonly gateway: NotificationsGateway, private readonly emailService: EmailService, private readonly pushService: PushService, private readonly preferencesService: PreferencesService, ) {} async send(dto: SendNotificationDto): Promise { // Get user preferences const preferences = await this.preferencesService.get(dto.userId, dto.type); // Filter channels based on preferences const effectiveChannels = dto.channels.filter(channel => { switch (channel) { case 'in_app': return preferences.inApp; case 'email': return preferences.email; case 'push': return preferences.push; default: return false; } }); if (effectiveChannels.length === 0) { return null; // User disabled all channels for this type } // Create in-app notification let notification: Notification = null; if (effectiveChannels.includes('in_app')) { notification = this.repo.create({ ...dto, channels: effectiveChannels, }); await this.repo.save(notification); // Send via WebSocket await this.gateway.sendToUser(dto.userId, { type: 'notification', payload: notification, }); } // Queue email if (effectiveChannels.includes('email')) { await this.emailService.queue({ tenantId: dto.tenantId, userId: dto.userId, template: this.getEmailTemplate(dto.type), variables: { title: dto.title, body: dto.body, ...dto.data }, frequency: preferences.emailFrequency, }); } // Queue push if (effectiveChannels.includes('push')) { await this.pushService.queue({ userId: dto.userId, title: dto.title, body: dto.body, data: dto.data, actionUrl: dto.actionUrl, }); } return notification; } async sendBulk( userIds: string[], dto: Omit ): Promise { let count = 0; for (const userId of userIds) { const notification = await this.send({ ...dto, userId }); if (notification) count++; } return count; } async findByUser( userId: string, query: QueryNotificationsDto ): Promise> { const qb = this.repo.createQueryBuilder('n') .where('n.user_id = :userId', { userId }); if (query.isRead !== undefined) { qb.andWhere('n.is_read = :isRead', { isRead: query.isRead }); } if (query.type) { qb.andWhere('n.type = :type', { type: query.type }); } if (!query.includeArchived) { qb.andWhere('n.is_archived = false'); } // Exclude expired qb.andWhere('(n.expires_at IS NULL OR n.expires_at > NOW())'); qb.orderBy('n.created_at', 'DESC'); return paginate(qb, query); } async markAsRead( userId: string, ids?: string[] ): Promise<{ affected: number }> { const qb = this.repo.createQueryBuilder() .update() .set({ isRead: true, readAt: new Date() }) .where('user_id = :userId', { userId }) .andWhere('is_read = false'); if (ids?.length) { qb.andWhere('id IN (:...ids)', { ids }); } const result = await qb.execute(); // Notify via WebSocket await this.gateway.sendToUser(userId, { type: 'notifications_read', payload: { ids, all: !ids }, }); return { affected: result.affected }; } async getUnreadCount(userId: string): Promise { const result = await this.repo .createQueryBuilder('n') .select('n.type', 'type') .addSelect('COUNT(*)', 'count') .where('n.user_id = :userId', { userId }) .andWhere('n.is_read = false') .andWhere('n.is_archived = false') .andWhere('(n.expires_at IS NULL OR n.expires_at > NOW())') .groupBy('n.type') .getRawMany(); const total = result.reduce((sum, r) => sum + parseInt(r.count), 0); const byType = result.reduce((acc, r) => { acc[r.type] = parseInt(r.count); return acc; }, {}); return { total, byType }; } async archive(userId: string, ids: string[]): Promise { await this.repo.update( { userId, id: In(ids) }, { isArchived: true } ); } } ``` ### EmailService ```typescript @Injectable() export class EmailService { constructor( @InjectRepository(EmailJob) private readonly jobRepo: Repository, @InjectRepository(EmailTemplate) private readonly templateRepo: Repository, @InjectQueue('email') private readonly queue: Queue, private readonly configService: ConfigService, ) {} async queue(dto: QueueEmailDto): Promise { // Get template const template = await this.templateRepo.findOne({ where: [ { key: dto.template, tenantId: dto.tenantId }, { key: dto.template, tenantId: IsNull() }, ], order: { tenantId: 'DESC' }, // Prefer tenant-specific }); if (!template) { throw new NotFoundException(`Email template ${dto.template} not found`); } // Get user email const user = await this.usersService.findById(dto.userId); // Render template const subject = this.render(template.subject, dto.variables); const bodyHtml = this.render(template.bodyHtml, dto.variables); const bodyText = template.bodyText ? this.render(template.bodyText, dto.variables) : null; // Calculate scheduled time based on frequency const scheduledAt = this.calculateScheduledTime(dto.frequency); // Create job const job = this.jobRepo.create({ tenantId: dto.tenantId, templateId: template.id, toEmail: user.email, toName: user.fullName, subject, bodyHtml, bodyText, variables: dto.variables, scheduledAt, }); await this.jobRepo.save(job); // Add to Bull queue await this.queue.add('send', { jobId: job.id }, { delay: scheduledAt.getTime() - Date.now(), attempts: 3, backoff: { type: 'exponential', delay: 60000 }, }); return job; } async sendDirect(dto: SendEmailDirectDto): Promise { const transporter = nodemailer.createTransport({ host: this.configService.get('SMTP_HOST'), port: this.configService.get('SMTP_PORT'), secure: this.configService.get('SMTP_SECURE', false), auth: { user: this.configService.get('SMTP_USER'), pass: this.configService.get('SMTP_PASS'), }, }); await transporter.sendMail({ from: this.configService.get('EMAIL_FROM'), to: dto.to, subject: dto.subject, html: dto.bodyHtml, text: dto.bodyText, attachments: dto.attachments, }); } private render(template: string, variables: Record): string { return Handlebars.compile(template)(variables); } private calculateScheduledTime(frequency: EmailFrequency): Date { const now = new Date(); switch (frequency) { case 'instant': return now; case 'hourly': return startOfHour(addHours(now, 1)); case 'daily': return startOfDay(addDays(now, 1)); case 'weekly': return startOfWeek(addWeeks(now, 1)); default: return now; } } } ``` ### PushService ```typescript @Injectable() export class PushService { private readonly webpush: typeof webpush; constructor( @InjectRepository(PushSubscription) private readonly subscriptionRepo: Repository, @InjectRepository(PushJob) private readonly jobRepo: Repository, @InjectQueue('push') private readonly queue: Queue, private readonly configService: ConfigService, ) { webpush.setVapidDetails( this.configService.get('PUSH_SUBJECT'), this.configService.get('VAPID_PUBLIC_KEY'), this.configService.get('VAPID_PRIVATE_KEY'), ); this.webpush = webpush; } async subscribe( userId: string, dto: SubscribePushDto ): Promise { // Check if already exists const existing = await this.subscriptionRepo.findOne({ where: { endpoint: dto.endpoint }, }); if (existing) { // Update and return existing.isActive = true; existing.lastUsedAt = new Date(); return this.subscriptionRepo.save(existing); } const subscription = this.subscriptionRepo.create({ userId, endpoint: dto.endpoint, p256dh: dto.keys.p256dh, auth: dto.keys.auth, deviceType: dto.deviceType, deviceName: dto.deviceName, browser: dto.browser, }); return this.subscriptionRepo.save(subscription); } async unsubscribe(userId: string, endpoint: string): Promise { await this.subscriptionRepo.update( { userId, endpoint }, { isActive: false } ); } async queue(dto: QueuePushDto): Promise { // Get all active subscriptions for user const subscriptions = await this.subscriptionRepo.find({ where: { userId: dto.userId, isActive: true }, }); for (const subscription of subscriptions) { const job = this.jobRepo.create({ subscriptionId: subscription.id, title: dto.title, body: dto.body, icon: dto.icon, data: dto.data, actionUrl: dto.actionUrl, }); await this.jobRepo.save(job); await this.queue.add('send', { jobId: job.id }, { attempts: 3, backoff: { type: 'exponential', delay: 30000 }, }); } } async send(job: PushJob): Promise { const subscription = await this.subscriptionRepo.findOne({ where: { id: job.subscriptionId }, }); if (!subscription || !subscription.isActive) { return; } const payload = JSON.stringify({ title: job.title, body: job.body, icon: job.icon, data: job.data, actionUrl: job.actionUrl, }); try { await this.webpush.sendNotification( { endpoint: subscription.endpoint, keys: { p256dh: subscription.p256dh, auth: subscription.auth, }, }, payload ); subscription.lastUsedAt = new Date(); await this.subscriptionRepo.save(subscription); } catch (error) { if (error.statusCode === 410) { // Subscription expired subscription.isActive = false; await this.subscriptionRepo.save(subscription); } throw error; } } } ``` ### PreferencesService ```typescript @Injectable() export class PreferencesService { constructor( @InjectRepository(NotificationPreference) private readonly repo: Repository, ) {} async get( userId: string, type?: string ): Promise { if (type) { const pref = await this.repo.findOne({ where: { userId, notificationType: type }, }); return pref || this.getDefaults(type); } const prefs = await this.repo.find({ where: { userId } }); return this.mergeWithDefaults(prefs); } async update( userId: string, updates: UpdatePreferencesDto ): Promise { const results: NotificationPreference[] = []; for (const [type, settings] of Object.entries(updates)) { await this.repo.upsert( { userId, notificationType: type, ...settings, updatedAt: new Date(), }, ['userId', 'notificationType'] ); const updated = await this.repo.findOne({ where: { userId, notificationType: type }, }); results.push(updated); } return results; } async reset(userId: string, types?: string[]): Promise { const qb = this.repo.createQueryBuilder() .delete() .where('user_id = :userId', { userId }); if (types?.length) { qb.andWhere('notification_type IN (:...types)', { types }); } await qb.execute(); } private getDefaults(type: string): NotificationPreference { return { inApp: true, email: true, push: false, emailFrequency: 'instant', } as NotificationPreference; } } ``` --- ## WebSocket Gateway ```typescript @WebSocketGateway({ namespace: '/notifications', cors: { origin: '*' }, }) export class NotificationsGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() private server: Server; private userSockets: Map> = new Map(); constructor( private readonly jwtService: JwtService, private readonly notificationsService: NotificationsService, ) {} async handleConnection(client: Socket): Promise { try { const token = client.handshake.auth.token; const payload = this.jwtService.verify(token); const userId = payload.sub; client.data.userId = userId; if (!this.userSockets.has(userId)) { this.userSockets.set(userId, new Set()); } this.userSockets.get(userId).add(client.id); client.join(`user:${userId}`); // Send initial unread count const count = await this.notificationsService.getUnreadCount(userId); client.emit('unread_count', count); } catch (error) { client.disconnect(); } } handleDisconnect(client: Socket): void { const userId = client.data.userId; if (userId && this.userSockets.has(userId)) { this.userSockets.get(userId).delete(client.id); if (this.userSockets.get(userId).size === 0) { this.userSockets.delete(userId); } } } async sendToUser(userId: string, message: WsMessage): Promise { this.server.to(`user:${userId}`).emit(message.type, message.payload); } @SubscribeMessage('mark_read') async handleMarkRead( client: Socket, payload: { ids?: string[] } ): Promise { const userId = client.data.userId; await this.notificationsService.markAsRead(userId, payload.ids); } @SubscribeMessage('ping') handlePing(): string { return 'pong'; } } ``` --- ## Job Processors ### EmailProcessor ```typescript @Processor('email') export class EmailProcessor { constructor( @InjectRepository(EmailJob) private readonly jobRepo: Repository, private readonly emailService: EmailService, ) {} @Process('send') async handleSend(job: Job<{ jobId: string }>): Promise { const emailJob = await this.jobRepo.findOne({ where: { id: job.data.jobId }, }); if (!emailJob || emailJob.status === 'sent') { return; } emailJob.status = 'processing'; emailJob.attempts += 1; await this.jobRepo.save(emailJob); try { await this.emailService.sendDirect({ to: emailJob.toEmail, toName: emailJob.toName, subject: emailJob.subject, bodyHtml: emailJob.bodyHtml, bodyText: emailJob.bodyText, }); emailJob.status = 'sent'; emailJob.sentAt = new Date(); emailJob.error = null; } catch (error) { emailJob.error = error.message; if (emailJob.attempts >= emailJob.maxAttempts) { emailJob.status = 'failed'; } else { emailJob.status = 'pending'; } throw error; // Retry } finally { await this.jobRepo.save(emailJob); } } } ``` ### PushProcessor ```typescript @Processor('push') export class PushProcessor { constructor( @InjectRepository(PushJob) private readonly jobRepo: Repository, private readonly pushService: PushService, ) {} @Process('send') async handleSend(job: Job<{ jobId: string }>): Promise { const pushJob = await this.jobRepo.findOne({ where: { id: job.data.jobId }, }); if (!pushJob || pushJob.status === 'sent') { return; } pushJob.status = 'processing'; pushJob.attempts += 1; await this.jobRepo.save(pushJob); try { await this.pushService.send(pushJob); pushJob.status = 'sent'; pushJob.sentAt = new Date(); pushJob.error = null; } catch (error) { pushJob.error = error.message; if (pushJob.attempts >= 3) { pushJob.status = 'failed'; } else { pushJob.status = 'pending'; throw error; // Retry } } finally { await this.jobRepo.save(pushJob); } } } ``` --- ## Controladores ### NotificationsController ```typescript @ApiTags('Notifications') @Controller('notifications') @UseGuards(JwtAuthGuard) export class NotificationsController { constructor(private readonly service: NotificationsService) {} @Get() async findAll( @CurrentUser() user: User, @Query() query: QueryNotificationsDto ) { return this.service.findByUser(user.id, query); } @Get('count') async getUnreadCount(@CurrentUser() user: User) { return this.service.getUnreadCount(user.id); } @Put('read') async markAllAsRead(@CurrentUser() user: User) { return this.service.markAsRead(user.id); } @Put(':id/read') async markAsRead( @CurrentUser() user: User, @Param('id') id: string ) { return this.service.markAsRead(user.id, [id]); } @Delete(':id') async archive( @CurrentUser() user: User, @Param('id') id: string ) { return this.service.archive(user.id, [id]); } } ``` ### PushController ```typescript @ApiTags('Push Notifications') @Controller('notifications/push') @UseGuards(JwtAuthGuard) export class PushController { constructor(private readonly service: PushService) {} @Get('vapid-key') getVapidKey() { return { publicKey: this.configService.get('VAPID_PUBLIC_KEY'), }; } @Post('subscribe') async subscribe( @CurrentUser() user: User, @Body() dto: SubscribePushDto ) { return this.service.subscribe(user.id, dto); } @Delete('unsubscribe') async unsubscribe( @CurrentUser() user: User, @Body('endpoint') endpoint: string ) { return this.service.unsubscribe(user.id, endpoint); } @Get('subscriptions') async getSubscriptions(@CurrentUser() user: User) { return this.service.getSubscriptions(user.id); } } ``` ### PreferencesController ```typescript @ApiTags('Notification Preferences') @Controller('notifications/preferences') @UseGuards(JwtAuthGuard) export class PreferencesController { constructor(private readonly service: PreferencesService) {} @Get() async getAll(@CurrentUser() user: User) { return this.service.get(user.id); } @Get(':type') async getOne( @CurrentUser() user: User, @Param('type') type: string ) { return this.service.get(user.id, type); } @Patch() async update( @CurrentUser() user: User, @Body() dto: UpdatePreferencesDto ) { return this.service.update(user.id, dto); } @Delete() async reset( @CurrentUser() user: User, @Body('types') types?: string[] ) { return this.service.reset(user.id, types); } } ``` --- ## API Endpoints Summary | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | /notifications | User | List notifications | | GET | /notifications/count | User | Get unread count | | PUT | /notifications/read | User | Mark all as read | | PUT | /notifications/:id/read | User | Mark one as read | | DELETE | /notifications/:id | User | Archive notification | | GET | /notifications/push/vapid-key | - | Get VAPID public key | | POST | /notifications/push/subscribe | User | Subscribe to push | | DELETE | /notifications/push/unsubscribe | User | Unsubscribe | | GET | /notifications/push/subscriptions | User | List subscriptions | | GET | /notifications/preferences | User | Get preferences | | GET | /notifications/preferences/:type | User | Get by type | | PATCH | /notifications/preferences | User | Update preferences | | DELETE | /notifications/preferences | User | Reset to defaults | | GET | /admin/notifications/templates | Admin | List templates | | POST | /admin/notifications/templates | Admin | Create template | | PATCH | /admin/notifications/templates/:id | Admin | Update template | | DELETE | /admin/notifications/templates/:id | Admin | Delete template | | POST | /admin/notifications/send | Admin | Send notification | --- ## WebSocket Events | Event | Direction | Description | |-------|-----------|-------------| | `notification` | Server -> Client | New notification received | | `unread_count` | Server -> Client | Updated unread count | | `notifications_read` | Server -> Client | Notifications marked as read | | `mark_read` | Client -> Server | Request to mark as read | | `ping` | Client -> Server | Heartbeat | --- ## Historial | Version | Fecha | Autor | Cambios | |---------|-------|-------|---------| | 1.0 | 2025-12-05 | Requirements-Analyst | Creacion inicial |