import models from "@models"; import { logger } from "ts-rails"; import { toNotificationDto, stringifyNotificationData, } from "./notificationMapper"; import { publishSseToUser } from "./sseHub"; import type { NotificationDto, SendNotificationInput, SendNotificationResult, } from "./types"; export async function sendNotification( input: SendNotificationInput, ): Promise { const userId = String(input.userId || "").trim(); if (!userId) throw new Error("userId is required"); const row = await models.appNotification.create({ data: { userId, type: input.type ?? "info", title: String(input.title || "").trim() || "Notification", message: input.message?.trim() || null, data: stringifyNotificationData(input.data), }, }); const notification = toNotificationDto(row); let sseDelivered = false; if (input.realtime !== false) { sseDelivered = (await broadcastNotificationsCenter(userId)) > 0; } let pushDelivered = 0; if (input.push) { try { const { sendPushToUser } = await import("./push"); pushDelivered = await sendPushToUser(userId, { title: notification.title, body: notification.message ?? undefined, }); } catch (e) { logger.warn({ err: String(e) }, "[notifications] FCM push failed"); } } return { notification, sseDelivered, pushDelivered }; } export async function listNotifications( userId: string, opts?: { limit?: number; unreadOnly?: boolean }, ): Promise { const limit = Math.min(100, Math.max(1, opts?.limit ?? 30)); const rows = await models.appNotification.findMany({ where: { userId, deleted: false, ...(opts?.unreadOnly ? { readAt: null } : {}), }, orderBy: { createdAt: "desc" }, take: limit, }); return rows.map(toNotificationDto); } export async function countUnread(userId: string): Promise { return models.appNotification.count({ where: { userId, deleted: false, readAt: null }, }); } export async function markNotificationRead( userId: string, notificationId: string, ): Promise { const row = await models.appNotification.findFirst({ where: { id: notificationId, userId, deleted: false }, }); if (!row) return null; if (row.readAt) return toNotificationDto(row); const updated = await models.appNotification.update({ where: { id: row.id }, data: { readAt: new Date() }, }); const dto = toNotificationDto(updated); await broadcastNotificationsCenter(userId); return dto; } export async function markAllNotificationsRead(userId: string): Promise { const r = await models.appNotification.updateMany({ where: { userId, deleted: false, readAt: null }, data: { readAt: new Date() }, }); await broadcastNotificationsCenter(userId); return r.count; } export async function buildSseConnectedPayload(userId: string): Promise<{ unreadCount: number; notifications: NotificationDto[]; }> { const [unreadCount, notifications] = await Promise.all([ countUnread(userId), listNotifications(userId, { limit: 20 }), ]); return { unreadCount, notifications }; } export type NotificationsCenterPayload = { notifications: NotificationDto[]; unreadCount: number; generatedAt: string; }; export async function loadNotificationsCenterPayload( userId: string, ): Promise { const base = await buildSseConnectedPayload(userId); return { notifications: base.notifications, unreadCount: base.unreadCount, generatedAt: new Date().toISOString(), }; } export async function broadcastNotificationsCenter(userId: string): Promise { try { const payload = await loadNotificationsCenterPayload(userId); return publishSseToUser(userId, "snapshot", payload); } catch (e) { logger.warn({ userId, err: String(e) }, "[notifications] broadcast failed"); return 0; } } export async function sendNotificationSafe( input: SendNotificationInput, ): Promise { try { return await sendNotification(input); } catch (e) { logger.error({ err: String(e), userId: input.userId }, "[notifications] send failed"); return null; } }