import { Injectable, Logger } from "@nestjs/common"; import { Prisma } from "@prisma/client"; import * as webPush from "web-push"; import { EmailService } from "../email/email.service"; import { PrismaService } from "../prisma/prisma.service"; import { SavePushSubscriptionDto, UpdateNotificationPreferencesDto } from "./notifications.dto"; type NotificationSeverity = "info" | "warning" | "critical"; type NotifyUserInput = { type: string; severity?: NotificationSeverity; title: string; body: string; metadata?: Record; }; const SEVERITY_RANK: Record = { info: 1, warning: 2, critical: 3, }; @Injectable() export class NotificationsService { private readonly logger = new Logger(NotificationsService.name); private readonly pushEnabled: boolean; private readonly appUrl = process.env.APP_URL ?? "http://localhost:3052"; constructor( private readonly prisma: PrismaService, private readonly emailService: EmailService, ) { const publicKey = process.env.VAPID_PUBLIC_KEY; const privateKey = process.env.VAPID_PRIVATE_KEY; const subject = process.env.VAPID_SUBJECT ?? "mailto:support@ledgerone.app"; this.pushEnabled = Boolean(publicKey && privateKey); if (this.pushEnabled) { webPush.setVapidDetails(subject, publicKey as string, privateKey as string); } } getVapidStatus() { return { enabled: this.pushEnabled, publicKey: this.pushEnabled ? process.env.VAPID_PUBLIC_KEY : null, }; } async list(userId: string, unreadOnly = false) { return this.prisma.notification.findMany({ where: { userId, ...(unreadOnly ? { readAt: null } : {}), }, orderBy: { createdAt: "desc" }, take: 50, }); } async getPreferences(userId: string) { const existing = await this.prisma.notificationPreference.findUnique({ where: { userId } }); if (existing) return existing; return this.prisma.notificationPreference.create({ data: { userId, emailEnabled: true, pushEnabled: false, minSeverity: "info", }, }); } async updatePreferences(userId: string, dto: UpdateNotificationPreferencesDto) { return this.prisma.notificationPreference.upsert({ where: { userId }, create: { userId, emailEnabled: dto.emailEnabled ?? true, pushEnabled: dto.pushEnabled ?? false, minSeverity: dto.minSeverity ?? "info", }, update: { ...(dto.emailEnabled !== undefined ? { emailEnabled: dto.emailEnabled } : {}), ...(dto.pushEnabled !== undefined ? { pushEnabled: dto.pushEnabled } : {}), ...(dto.minSeverity ? { minSeverity: dto.minSeverity } : {}), }, }); } async savePushSubscription(userId: string, dto: SavePushSubscriptionDto, userAgent?: string) { const subscription = await this.prisma.pushSubscription.upsert({ where: { endpoint: dto.endpoint }, create: { userId, endpoint: dto.endpoint, p256dh: dto.keys.p256dh, auth: dto.keys.auth, userAgent, }, update: { userId, p256dh: dto.keys.p256dh, auth: dto.keys.auth, userAgent, revokedAt: null, }, }); await this.updatePreferences(userId, { pushEnabled: true }); return subscription; } async markRead(userId: string, notificationId: string) { return this.prisma.notification.updateMany({ where: { id: notificationId, userId }, data: { readAt: new Date() }, }); } async markAllRead(userId: string) { return this.prisma.notification.updateMany({ where: { userId, readAt: null }, data: { readAt: new Date() }, }); } async sendTestNotification(userId: string) { return this.notifyUser(userId, { type: "notification.test", severity: "info", title: "LedgerOne notification test", body: "SMTP and push notification delivery are configured for your account.", metadata: { source: "settings_test" }, }); } async notifyUser(userId: string, input: NotifyUserInput) { const severity = input.severity ?? "info"; const channels = ["in_app"]; const notification = await this.prisma.notification.create({ data: { userId, type: input.type, severity, title: input.title, body: input.body, metadata: (input.metadata ?? {}) as Prisma.InputJsonValue, channels: [...channels], }, }); const [preferences, user] = await Promise.all([ this.getPreferences(userId), this.prisma.user.findUnique({ where: { id: userId }, select: { email: true } }), ]); if (this.shouldSend(preferences.minSeverity as NotificationSeverity, severity)) { if (preferences.emailEnabled && user?.email) { await this.emailService.sendNotificationEmail( user.email, input.title, input.body, `${this.appUrl}/notifications`, severity, ); channels.push("email"); } if (preferences.pushEnabled && this.pushEnabled) { const sent = await this.sendPushNotifications(userId, notification.id, input, severity); if (sent > 0) channels.push("push"); } } return this.prisma.notification.update({ where: { id: notification.id }, data: { channels }, }); } private shouldSend(minSeverity: NotificationSeverity, severity: NotificationSeverity) { return SEVERITY_RANK[severity] >= SEVERITY_RANK[minSeverity ?? "info"]; } private async sendPushNotifications( userId: string, notificationId: string, input: NotifyUserInput, severity: NotificationSeverity, ) { const subscriptions = await this.prisma.pushSubscription.findMany({ where: { userId, revokedAt: null }, }); let sent = 0; const payload = JSON.stringify({ title: input.title, body: input.body, url: "/notifications", notificationId, type: input.type, severity, }); for (const subscription of subscriptions) { try { await webPush.sendNotification( { endpoint: subscription.endpoint, keys: { p256dh: subscription.p256dh, auth: subscription.auth, }, }, payload, ); sent += 1; await this.prisma.pushSubscription.update({ where: { id: subscription.id }, data: { lastUsedAt: new Date() }, }); } catch (err) { const statusCode = typeof err === "object" && err && "statusCode" in err ? Number((err as { statusCode?: number }).statusCode) : 0; if (statusCode === 404 || statusCode === 410) { await this.prisma.pushSubscription.update({ where: { id: subscription.id }, data: { revokedAt: new Date() }, }); } else { this.logger.warn(`Push notification failed for subscription ${subscription.id}`); } } } return sent; } }