384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
import { buildNotificationJob, enqueueJob, queueNames, resolveRedisConnectionOptions, type RedisConnectionEnv, type TypedQueue } from "@toolctrl/queue";
|
|
import { createNotificationDelivery, redactNotificationDiagnosticText } from "@toolctrl/db/notifications.server";
|
|
import { getRolesByName } from "~/utils/db/permissions/roles.db.server";
|
|
import { getUsersRolesInTenant } from "~/utils/db/permissions/userRoles.db.server";
|
|
import { getAdminUsersInRoles } from "~/utils/db/users.db.server";
|
|
import { NotificationIntegrationDto } from "../../dtos/NotificationIntegrationDto";
|
|
import { NotificationMessageDto } from "../../dtos/NotificationMessageDto";
|
|
import { NotificationSubscriberDto } from "../../dtos/NotificationSubscriberDto";
|
|
import { NotificationTemplateDto } from "../../dtos/NotificationTemplateDto";
|
|
import { NotificationChannel, NotificationChannels } from "../NotificationChannels";
|
|
|
|
type NotificationQueue = TypedQueue<typeof queueNames.notifications>;
|
|
|
|
async function withNotificationQueue<T>(env: RedisConnectionEnv | undefined, task: (queue: NotificationQueue) => Promise<T>): Promise<T | undefined> {
|
|
const connection = resolveRedisConnectionOptions({ env });
|
|
if (!connection) {
|
|
return undefined;
|
|
}
|
|
|
|
const queueRuntimeModule = "@toolctrl/queue/runtime";
|
|
const { createQueue } = (await import(/* @vite-ignore */ queueRuntimeModule)) as typeof import("@toolctrl/queue/runtime");
|
|
const queue = createQueue(queueNames.notifications, { connection });
|
|
try {
|
|
return await task(queue);
|
|
} finally {
|
|
await queue.close();
|
|
}
|
|
}
|
|
|
|
async function novuApiFetch<T>(path: string, init?: RequestInit): Promise<T | null> {
|
|
if (!process.env.NOTIFICATIONS_NOVU_API_KEY) {
|
|
return null;
|
|
}
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`https://api.novu.co/v1${path}`, {
|
|
...init,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `ApiKey ${process.env.NOTIFICATIONS_NOVU_API_KEY}`,
|
|
...(init?.headers ?? {}),
|
|
},
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
try {
|
|
return (await response.json()) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export interface IGetSubscribersData {
|
|
page: number;
|
|
totalCount: number;
|
|
pageSize: number;
|
|
data: NotificationSubscriberDto[];
|
|
}
|
|
async function getSubscribers({ page }: { page?: number }): Promise<IGetSubscribersData | null> {
|
|
const query = new URLSearchParams({ page: String((page ?? 1) - 1) });
|
|
return novuApiFetch<IGetSubscribersData>(`/subscribers?${query}`);
|
|
}
|
|
|
|
export interface IGetMessagesData {
|
|
page: number;
|
|
totalCount: number;
|
|
pageSize: number;
|
|
data: NotificationMessageDto[];
|
|
}
|
|
async function getMessages({ channel, subscriberId, limit, page }: { channel?: string; subscriberId?: string; limit?: number; page?: number }) {
|
|
let query = new URLSearchParams();
|
|
if (channel) {
|
|
query.append("channel", channel);
|
|
}
|
|
if (subscriberId) {
|
|
query.append("subscriberId", subscriberId);
|
|
}
|
|
if (limit) {
|
|
query.append("limit", limit.toString());
|
|
}
|
|
if (page === undefined) {
|
|
page = 1;
|
|
}
|
|
if (page) {
|
|
query.append("page", (page - 1).toString());
|
|
}
|
|
return novuApiFetch<IGetMessagesData>(`/messages?${query}`);
|
|
}
|
|
|
|
export interface IGetTemplatesData {
|
|
page: number;
|
|
totalCount: number;
|
|
pageSize: number;
|
|
data: NotificationTemplateDto[];
|
|
}
|
|
async function getNotificationTemplates({ limit, page }: { limit?: number; page?: number }) {
|
|
const query = new URLSearchParams();
|
|
if (limit) query.append("limit", String(limit));
|
|
if (page) query.append("page", String(page));
|
|
return novuApiFetch<IGetTemplatesData>(`/notification-templates?${query}`);
|
|
}
|
|
|
|
// interface ICreateNotificationTemplateArgs {
|
|
// name: string;
|
|
// notificationGroupId: string;
|
|
// tags?: string[];
|
|
// description?: string;
|
|
// steps?: {
|
|
// template: {
|
|
// type: "in_app" | "email" | "sms" | "chat" | "push" | "digest" | "trigger" | "delay";
|
|
// variables: {
|
|
// type: "String";
|
|
// name: string;
|
|
// defaultValue: string;
|
|
// required: boolean;
|
|
// }[];
|
|
// content: string;
|
|
// };
|
|
// }[];
|
|
// active?: boolean;
|
|
// draft?: boolean;
|
|
// critical?: boolean;
|
|
// preferenceSettings?: IPreferenceChannels;
|
|
// }
|
|
// async function createNotificationTemplate(data: ICreateNotificationTemplateArgs) {
|
|
// const novu = getClient();
|
|
// if (!novu) {
|
|
// return;
|
|
// }
|
|
// const response = await novu.notificationTemplates.create(data);
|
|
// return response.data as NotificationTemplateDto;
|
|
// }
|
|
|
|
export interface IGetIntegrationsData {
|
|
data: NotificationIntegrationDto[];
|
|
}
|
|
async function getIntegrations() {
|
|
return novuApiFetch<IGetIntegrationsData>("/integrations");
|
|
}
|
|
|
|
type NotificationAttachmentChannel = "in_app" | "email" | "sms" | "chat" | "push" | "digest" | "trigger" | "delay";
|
|
interface IAttachmentOptions {
|
|
mime: string;
|
|
file: Buffer;
|
|
name?: string;
|
|
channels?: NotificationAttachmentChannel[];
|
|
}
|
|
interface INotificationUser {
|
|
id: string;
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
phone: string | null;
|
|
defaultTenantId?: string | null;
|
|
}
|
|
|
|
interface INotification {
|
|
from?: { user?: INotificationUser | null };
|
|
message: string;
|
|
action?: {
|
|
title?: string;
|
|
url: string;
|
|
};
|
|
data?: { [key: string]: string | string[] | boolean | number | undefined | IAttachmentOptions | IAttachmentOptions[] | Record<string, unknown> };
|
|
attachments?: IAttachmentOptions[];
|
|
}
|
|
|
|
function getNotificationSourceKey(channel: string, notification: INotification): string | undefined {
|
|
const directSourceKey = typeof notification.data?.sourceKey === "string" ? notification.data.sourceKey : undefined;
|
|
const notificationSourceKey = typeof notification.data?.notificationSourceKey === "string" ? notification.data.notificationSourceKey : undefined;
|
|
const normalized = (directSourceKey ?? notificationSourceKey)?.trim();
|
|
if (normalized) {
|
|
return normalized;
|
|
}
|
|
|
|
const requestId = typeof notification.data?.requestId === "string" ? notification.data.requestId.trim() : "";
|
|
return requestId ? `${channel}:request:${requestId}` : undefined;
|
|
}
|
|
|
|
function createSkippedNotificationDelivery({
|
|
targetTenantId,
|
|
to,
|
|
channel,
|
|
reason,
|
|
lastError,
|
|
}: {
|
|
targetTenantId: string;
|
|
to: INotificationUser;
|
|
channel: string;
|
|
reason: string;
|
|
lastError: string;
|
|
}) {
|
|
return createNotificationDelivery({
|
|
tenantId: targetTenantId,
|
|
userId: to.id,
|
|
channel: "in-app",
|
|
category: mapNotificationChannelToCategory(channel),
|
|
messageKey: channel,
|
|
status: "SKIPPED",
|
|
lastError,
|
|
payloadMetadata: {
|
|
source: "legacy-notification-service",
|
|
reason,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function send({
|
|
channel,
|
|
to,
|
|
notification,
|
|
tenantId,
|
|
required = false,
|
|
}: {
|
|
channel: string;
|
|
to: INotificationUser;
|
|
notification: INotification;
|
|
tenantId?: string | null;
|
|
required?: boolean;
|
|
}) {
|
|
if (notification.from?.user?.id === to.id) {
|
|
const isLocalDevelopmentMode = process.env.NODE_ENV === "development";
|
|
if (!isLocalDevelopmentMode) {
|
|
return;
|
|
}
|
|
// eslint-disable-next-line no-console
|
|
console.log("[LOCAL DEV] Skipping sending notification to self.");
|
|
notification.message = `[dev] ${notification.message}`;
|
|
}
|
|
|
|
if (notification.from?.user?.email === to.email) {
|
|
if (process.env.NODE_ENV !== "development") {
|
|
return;
|
|
}
|
|
// eslint-disable-next-line no-console
|
|
console.log("[LOCAL DEV] Skipping sending notification to self.");
|
|
}
|
|
if (notification.action && !notification.action?.title) {
|
|
notification.action.title = "View";
|
|
}
|
|
|
|
const targetTenantId = tenantId ?? (typeof notification.data?.tenantId === "string" ? notification.data.tenantId : undefined) ?? to.defaultTenantId;
|
|
if (!targetTenantId) {
|
|
const message = "Notification delivery requires tenantId.";
|
|
if (process.env.NODE_ENV !== "test") {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(`[NotificationService.send()] ${message}`);
|
|
}
|
|
if (required) {
|
|
throw new Error(message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const queued = await withNotificationQueue(process.env as RedisConnectionEnv, async (queue) => {
|
|
const descriptor = buildNotificationJob({
|
|
tenantId: targetTenantId,
|
|
userId: to.id,
|
|
email: to.email,
|
|
channel: "in-app",
|
|
category: mapNotificationChannelToCategory(channel),
|
|
messageKey: channel,
|
|
sourceKey: getNotificationSourceKey(channel, notification),
|
|
metadata: {
|
|
source: "legacy-notification-service",
|
|
...(notification.action?.url ? { actionUrl: notification.action.url } : {}),
|
|
...(notification.message ? { message: notification.message } : {}),
|
|
},
|
|
templateData: {
|
|
...notification.data,
|
|
from: notification.from,
|
|
message: notification.message,
|
|
action: notification.action,
|
|
attachments: notification.attachments,
|
|
},
|
|
});
|
|
return enqueueJob(queue, descriptor, { tenantId: targetTenantId });
|
|
});
|
|
if (!queued && required) {
|
|
throw new Error("Notification queue is not configured.");
|
|
}
|
|
if (!queued) {
|
|
return createSkippedNotificationDelivery({
|
|
targetTenantId,
|
|
to,
|
|
channel,
|
|
reason: "queue-not-configured",
|
|
lastError: "Notification queue is not configured.",
|
|
});
|
|
}
|
|
return queued;
|
|
} catch (e: any) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
const redactedMessage = redactNotificationDiagnosticText(message);
|
|
// eslint-disable-next-line no-console
|
|
console.error("[NotificationService.send()] Error: " + redactedMessage);
|
|
if (required) {
|
|
throw new Error(redactedMessage);
|
|
}
|
|
return createSkippedNotificationDelivery({
|
|
targetTenantId,
|
|
to,
|
|
channel,
|
|
reason: "queue-enqueue-failed",
|
|
lastError: redactedMessage,
|
|
});
|
|
}
|
|
}
|
|
|
|
export function mapNotificationChannelToCategory(channel: string) {
|
|
if (channel === "admin-subscriptions") {
|
|
return "billing";
|
|
}
|
|
if (channel.startsWith("admin-")) {
|
|
return "team";
|
|
}
|
|
if (channel === "my-rows" || channel === "roles") {
|
|
return "team";
|
|
}
|
|
return "default";
|
|
}
|
|
|
|
async function sendToRoles({ channel, notification, tenantId }: { channel: NotificationChannel; tenantId?: string | null; notification: INotification }) {
|
|
const notificationChannel = NotificationChannels.find((f) => f.name === channel);
|
|
if (!notificationChannel?.roles) {
|
|
return;
|
|
}
|
|
const allRoles = await getRolesByName(notificationChannel.roles);
|
|
const adminRoles = allRoles.filter((f) => f.type === "admin");
|
|
const appRoles = allRoles.filter((f) => f.type === "app");
|
|
|
|
const sentToUsers: string[] = [];
|
|
|
|
const adminUsersInRoles = await getAdminUsersInRoles(adminRoles.map((m) => m.id));
|
|
await Promise.all(
|
|
adminUsersInRoles.map(async (adminUser) => {
|
|
if (sentToUsers.includes(adminUser.id)) {
|
|
return;
|
|
}
|
|
sentToUsers.push(adminUser.id);
|
|
await send({ channel: notificationChannel.name, to: adminUser, tenantId: tenantId ?? adminUser.defaultTenantId, notification });
|
|
})
|
|
);
|
|
|
|
if (tenantId) {
|
|
const tenantUserRoles = await getUsersRolesInTenant(tenantId);
|
|
await Promise.all(
|
|
appRoles.map(async (role) => {
|
|
const usersInRole = tenantUserRoles.filter((f) => f.roleId === role.id);
|
|
return await Promise.all(
|
|
usersInRole.map(async ({ user }) => {
|
|
if (sentToUsers.includes(user.id)) {
|
|
return;
|
|
}
|
|
sentToUsers.push(user.id);
|
|
return await send({ channel: notificationChannel.name, to: user, tenantId, notification });
|
|
})
|
|
);
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
async function deleteNotificationTemplate(id: string) {
|
|
await novuApiFetch(`/notification-templates/${id}/status`, { method: "PATCH", body: JSON.stringify({ active: false }) });
|
|
return novuApiFetch(`/notification-templates/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
export default {
|
|
getSubscribers,
|
|
getMessages,
|
|
getIntegrations,
|
|
getNotificationTemplates,
|
|
// createNotificationTemplate,
|
|
deleteNotificationTemplate,
|
|
send,
|
|
sendToRoles,
|
|
};
|