erp-core/backend/src/modules/system/notifications.service.ts
rckrdmrd 4c4e27d9ba feat: Documentation and orchestration updates
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:35:20 -06:00

279 lines
7.9 KiB
TypeScript

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<Notification>(
`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<Notification[]> {
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<Notification>(
`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<number> {
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<Notification> {
const notification = await queryOne<Notification>(
`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<Notification> {
const notification = await queryOne<Notification>(
`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<number> {
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<Notification> {
const existingNotification = await this.findById(id, tenantId);
const notification = await queryOne<Notification>(
`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<number> {
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<void> {
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<number> {
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();