ledgerone_backend/src/notifications/notifications.service.ts

242 lines
7.0 KiB
TypeScript

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<string, unknown>;
};
const SEVERITY_RANK: Record<NotificationSeverity, number> = {
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;
}
}