242 lines
7.0 KiB
TypeScript
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;
|
|
}
|
|
}
|