🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
279 lines
7.9 KiB
TypeScript
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();
|