import { query, queryOne } from '../../config/database.js'; import { NotFoundError } from '../../shared/errors/index.js'; import { notificationGateway } from '../notifications/websocket/index.js'; import { logger } from '../../shared/utils/logger.js'; export interface Notification { id: string; tenant_id: string; user_id: string; title: string; message: string; url?: string; model?: string; record_id?: string; status: 'pending' | 'sent' | 'read' | 'failed'; read_at?: Date; created_at: Date; sent_at?: Date; } export interface CreateNotificationDto { user_id: string; title: string; message: string; url?: string; model?: string; record_id?: string; } export interface NotificationFilters { user_id?: string; status?: string; unread_only?: boolean; model?: string; search?: string; page?: number; limit?: number; } class NotificationsService { async findAll(tenantId: string, filters: NotificationFilters = {}): Promise<{ data: Notification[]; total: number }> { const { user_id, status, unread_only, model, search, page = 1, limit = 50 } = filters; const offset = (page - 1) * limit; let whereClause = 'WHERE n.tenant_id = $1'; const params: any[] = [tenantId]; let paramIndex = 2; if (user_id) { whereClause += ` AND n.user_id = $${paramIndex++}`; params.push(user_id); } if (status) { whereClause += ` AND n.status = $${paramIndex++}`; params.push(status); } if (unread_only) { whereClause += ` AND n.read_at IS NULL`; } if (model) { whereClause += ` AND n.model = $${paramIndex++}`; params.push(model); } if (search) { whereClause += ` AND (n.title ILIKE $${paramIndex} OR n.message ILIKE $${paramIndex})`; params.push(`%${search}%`); paramIndex++; } const countResult = await queryOne<{ count: string }>( `SELECT COUNT(*) as count FROM system.notifications n ${whereClause}`, params ); params.push(limit, offset); const data = await query( `SELECT n.* FROM system.notifications n ${whereClause} ORDER BY n.created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, params ); return { data, total: parseInt(countResult?.count || '0', 10), }; } async findByUser(userId: string, tenantId: string, unreadOnly: boolean = false): Promise { let whereClause = 'WHERE n.user_id = $1 AND n.tenant_id = $2'; if (unreadOnly) { whereClause += ' AND n.read_at IS NULL'; } const notifications = await query( `SELECT n.* FROM system.notifications n ${whereClause} ORDER BY n.created_at DESC LIMIT 100`, [userId, tenantId] ); return notifications; } async getUnreadCount(userId: string, tenantId: string): Promise { const result = await queryOne<{ count: string }>( `SELECT COUNT(*) as count FROM system.notifications WHERE user_id = $1 AND tenant_id = $2 AND read_at IS NULL`, [userId, tenantId] ); return parseInt(result?.count || '0', 10); } async findById(id: string, tenantId: string): Promise { const notification = await queryOne( `SELECT n.* FROM system.notifications n WHERE n.id = $1 AND n.tenant_id = $2`, [id, tenantId] ); if (!notification) { throw new NotFoundError('Notificación no encontrada'); } return notification; } async create(dto: CreateNotificationDto, tenantId: string): Promise { const notification = await queryOne( `INSERT INTO system.notifications ( tenant_id, user_id, title, message, url, model, record_id, status, sent_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'sent', CURRENT_TIMESTAMP) RETURNING *`, [tenantId, dto.user_id, dto.title, dto.message, dto.url, dto.model, dto.record_id] ); // Emit real-time notification to user if (notification) { try { notificationGateway.emitNotificationNew(dto.user_id, notification); // Also emit updated unread count const unreadCount = await this.getUnreadCount(dto.user_id, tenantId); notificationGateway.emitNotificationCount(dto.user_id, unreadCount); } catch (error) { // Log but don't fail the create operation logger.warn('Failed to emit real-time notification', { error: error instanceof Error ? error.message : 'Unknown error', userId: dto.user_id, notificationId: notification.id, }); } } return notification!; } async createBulk(notifications: CreateNotificationDto[], tenantId: string): Promise { if (notifications.length === 0) return 0; const values = notifications.map((n, i) => { const base = i * 7; return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, 'sent', CURRENT_TIMESTAMP)`; }).join(', '); const params = notifications.flatMap(n => [ tenantId, n.user_id, n.title, n.message, n.url, n.model, n.record_id ]); const result = await query( `INSERT INTO system.notifications ( tenant_id, user_id, title, message, url, model, record_id, status, sent_at ) VALUES ${values}`, params ); return notifications.length; } async markAsRead(id: string, tenantId: string): Promise { const existingNotification = await this.findById(id, tenantId); const notification = await queryOne( `UPDATE system.notifications SET status = 'read', read_at = CURRENT_TIMESTAMP WHERE id = $1 AND tenant_id = $2 RETURNING *`, [id, tenantId] ); // Emit real-time update to user if (notification) { try { notificationGateway.emitNotificationRead( existingNotification.user_id, notification.id, notification.read_at! ); // Emit updated unread count const unreadCount = await this.getUnreadCount(existingNotification.user_id, tenantId); notificationGateway.emitNotificationCount(existingNotification.user_id, unreadCount); } catch (error) { logger.warn('Failed to emit notification read event', { error: error instanceof Error ? error.message : 'Unknown error', userId: existingNotification.user_id, notificationId: notification.id, }); } } return notification!; } async markAllAsRead(userId: string, tenantId: string): Promise { const result = await query( `UPDATE system.notifications SET status = 'read', read_at = CURRENT_TIMESTAMP WHERE user_id = $1 AND tenant_id = $2 AND read_at IS NULL`, [userId, tenantId] ); // Emit updated count (should be 0 after marking all as read) try { notificationGateway.emitNotificationCount(userId, 0); } catch (error) { logger.warn('Failed to emit notification count after marking all as read', { error: error instanceof Error ? error.message : 'Unknown error', userId, }); } return result.length; } async delete(id: string, tenantId: string): Promise { await this.findById(id, tenantId); await query( `DELETE FROM system.notifications WHERE id = $1 AND tenant_id = $2`, [id, tenantId] ); } async deleteOld(daysToKeep: number = 30, tenantId?: string): Promise { let whereClause = `WHERE read_at IS NOT NULL AND created_at < CURRENT_TIMESTAMP - INTERVAL '${daysToKeep} days'`; const params: any[] = []; if (tenantId) { whereClause += ' AND tenant_id = $1'; params.push(tenantId); } const result = await query( `DELETE FROM system.notifications ${whereClause}`, params ); return result.length; } } export const notificationsService = new NotificationsService();