Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/notifications/services/.server/NotificationService.ts
T

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,
};