sendou.ink/app/features/notifications/core/notify.server.ts
Kalle 89c9e897f0
Some checks failed
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled
Remove redundant log
2025-03-01 14:21:40 +02:00

138 lines
3.6 KiB
TypeScript

import type { TFunction } from "i18next";
import pLimit from "p-limit";
import { WebPushError } from "web-push";
import type { NotificationSubscription } from "../../../db/tables";
import i18next from "../../../modules/i18n/i18next.server";
import { logger } from "../../../utils/logger";
import * as NotificationRepository from "../NotificationRepository.server";
import type { Notification } from "../notifications-types";
import { notificationLink } from "../notifications-utils";
import webPush from "./webPush.server";
/**
* Create notifications both in the database and send push notifications to users (if enabled).
*/
export async function notify({
userIds,
notification,
defaultSeenUserIds,
}: {
/** Array of user ids to notify */
userIds: Array<number>;
/** Array of user ids that should have the notification marked as seen by default */
defaultSeenUserIds?: Array<number>;
/** Notification to send (same for all users) */
notification: Notification;
}) {
if (userIds.length === 0) {
return;
}
if (isNotificationAlreadySent(notification)) {
return;
}
const dededuplicatedUserIds = Array.from(new Set(userIds));
try {
await NotificationRepository.insert(
notification,
dededuplicatedUserIds.map((userId) => ({
userId,
seen: defaultSeenUserIds?.includes(userId) ? 1 : 0,
})),
);
} catch (e) {
console.error("Failed to notify users", e);
}
const subscriptions = await NotificationRepository.subscriptionsByUserIds(
dededuplicatedUserIds,
);
if (subscriptions.length > 0) {
const t = await i18next.getFixedT("en-US", ["common"]);
const limit = pLimit(50);
await Promise.all(
subscriptions.map(({ id, subscription }) =>
limit(() =>
sendPushNotification({
subscription,
subscriptionId: id,
notification,
t,
}),
),
),
);
}
}
const sentNotifications = new Set<string>();
// deduplicates notifications as a failsafe & anti-abuse mechanism
function isNotificationAlreadySent(notification: Notification) {
// e2e tests should not be affected by this
if (process.env.NODE_ENV !== "production") {
return false;
}
const key = `${notification.type}-${JSON.stringify(notification.meta)}`;
if (sentNotifications.has(key)) {
return true;
}
sentNotifications.add(key);
if (sentNotifications.size > 10_000) {
sentNotifications.clear();
}
return false;
}
async function sendPushNotification({
subscription,
subscriptionId,
notification,
t,
}: {
subscription: NotificationSubscription;
subscriptionId: number;
notification: Notification;
t: TFunction<["common"], undefined>;
}) {
try {
await webPush.sendNotification(
subscription,
JSON.stringify(pushNotificationOptions(notification, t)),
);
} catch (err) {
if (!(err instanceof WebPushError)) {
logger.error("Failed to send push notification (unknown error)", err);
// if we get "Not Found" or "Gone" we should delete the subscription as it is expired or no longer valid
} else if (err.statusCode === 404 || err.statusCode === 410) {
await NotificationRepository.deleteSubscriptionById(subscriptionId);
} else {
logger.error("Failed to send push notification", err);
}
}
}
function pushNotificationOptions(
notification: Notification,
t: TFunction<["common"], undefined>,
): Parameters<ServiceWorkerRegistration["showNotification"]>[1] & {
title: string;
} {
return {
title: t(`common:notifications.title.${notification.type}`),
body: t(
`common:notifications.text.${notification.type}`,
notification.meta,
),
icon: notification.pictureUrl ?? "/static-assets/img/app-icon.png",
data: { url: notificationLink(notification) },
};
}