apps/nestjs-backend/src/features/notification/notification.service.ts
import { Injectable, Logger } from '@nestjs/common';
import type { ISendMailOptions } from '@nestjs-modules/mailer';
import type { INotificationBuffer, INotificationUrl } from '@teable/core';
import {
generateNotificationId,
getUserNotificationChannel,
NotificationStatesEnum,
NotificationTypeEnum,
notificationUrlSchema,
userIconSchema,
SYSTEM_USER_ID,
assertNever,
} from '@teable/core';
import type { Prisma } from '@teable/db-main-prisma';
import { PrismaService } from '@teable/db-main-prisma';
import {
UploadType,
type IGetNotifyListQuery,
type INotificationUnreadCountVo,
type INotificationVo,
type IUpdateNotifyStatusRo,
} from '@teable/openapi';
import { keyBy } from 'lodash';
import { IMailConfig, MailConfig } from '../../configs/mail.config';
import { ShareDbService } from '../../share-db/share-db.service';
import StorageAdapter from '../attachments/plugins/adapter';
import { getFullStorageUrl } from '../attachments/plugins/utils';
import { MailSenderService } from '../mail-sender/mail-sender.service';
import { UserService } from '../user/user.service';
@Injectable()
export class NotificationService {
private readonly logger = new Logger(NotificationService.name);
constructor(
private readonly prismaService: PrismaService,
private readonly shareDbService: ShareDbService,
private readonly mailSenderService: MailSenderService,
private readonly userService: UserService,
@MailConfig() private readonly mailConfig: IMailConfig
) {}
async sendCollaboratorNotify(params: {
fromUserId: string;
toUserId: string;
refRecord: {
baseId: string;
tableId: string;
tableName: string;
fieldName: string;
recordIds: string[];
};
}): Promise<void> {
const { fromUserId, toUserId, refRecord } = params;
const [fromUser, toUser] = await Promise.all([
this.userService.getUserById(fromUserId),
this.userService.getUserById(toUserId),
]);
if (!fromUser || !toUser || fromUserId === toUserId) {
return;
}
const notifyId = generateNotificationId();
const emailOptions = this.mailSenderService.collaboratorCellTagEmailOptions({
notifyId,
fromUserName: fromUser.name,
refRecord,
});
const userIcon = userIconSchema.parse({
userId: fromUser.id,
userName: fromUser.name,
userAvatarUrl:
fromUser?.avatar &&
getFullStorageUrl(StorageAdapter.getBucket(UploadType.Avatar), fromUser.avatar),
});
const urlMeta = notificationUrlSchema.parse({
baseId: refRecord.baseId,
tableId: refRecord.tableId,
...(refRecord.recordIds.length === 1 ? { recordId: refRecord.recordIds[0] } : {}),
});
const type =
refRecord.recordIds.length > 1
? NotificationTypeEnum.CollaboratorMultiRowTag
: NotificationTypeEnum.CollaboratorCellTag;
const notifyPath = this.generateNotifyPath(type as NotificationTypeEnum, urlMeta);
const data: Prisma.NotificationCreateInput = {
id: notifyId,
fromUserId,
toUserId,
type,
message: emailOptions.notifyMessage,
urlPath: notifyPath,
createdBy: fromUserId,
};
const notifyData = await this.createNotify(data);
const unreadCount = (await this.unreadCount(toUser.id)).unreadCount;
const socketNotification = {
notification: {
id: notifyData.id,
message: notifyData.message,
notifyIcon: userIcon,
notifyType: notifyData.type as NotificationTypeEnum,
url: this.mailConfig.origin + notifyPath,
isRead: false,
createdTime: notifyData.createdTime.toISOString(),
},
unreadCount: unreadCount,
};
this.sendNotifyBySocket(toUser.id, socketNotification);
if (toUser.notifyMeta && toUser.notifyMeta.email) {
this.sendNotifyByMail(toUser.email, emailOptions);
}
}
async sendCommonNotify(
params: {
path: string;
fromUserId?: string;
toUserId: string;
message: string;
emailConfig?: {
title: string;
message: string;
buttonUrl?: string; // use path as default
buttonText?: string; // use 'View' as default
};
},
type = NotificationTypeEnum.System
) {
const { toUserId, emailConfig, message, path, fromUserId = SYSTEM_USER_ID } = params;
const notifyId = generateNotificationId();
const toUser = await this.userService.getUserById(toUserId);
if (!toUser) {
return;
}
const data: Prisma.NotificationCreateInput = {
id: notifyId,
fromUserId: fromUserId,
toUserId,
type,
urlPath: path,
createdBy: fromUserId,
message,
};
const notifyData = await this.createNotify(data);
const unreadCount = (await this.unreadCount(toUser.id)).unreadCount;
const rawUsers = await this.prismaService.user.findMany({
select: { id: true, name: true, avatar: true },
where: { id: fromUserId },
});
const fromUserSets = keyBy(rawUsers, 'id');
const systemNotifyIcon = this.generateNotifyIcon(
notifyData.type as NotificationTypeEnum,
fromUserId,
fromUserSets
);
const socketNotification = {
notification: {
id: notifyData.id,
message: notifyData.message,
notifyType: type,
url: this.mailConfig.origin + path,
notifyIcon: systemNotifyIcon,
isRead: false,
createdTime: notifyData.createdTime.toISOString(),
},
unreadCount: unreadCount,
};
this.sendNotifyBySocket(toUser.id, socketNotification);
if (emailConfig && toUser.notifyMeta && toUser.notifyMeta.email) {
const emailOptions = this.mailSenderService.commonEmailOptions({
...emailConfig,
to: toUserId,
buttonUrl: emailConfig.buttonUrl || this.mailConfig.origin + path,
buttonText: emailConfig.buttonText || 'View',
});
this.sendNotifyByMail(toUser.email, emailOptions);
}
}
async sendImportResultNotify(params: {
tableId: string;
baseId: string;
toUserId: string;
message: string;
}) {
const { toUserId, tableId, message, baseId } = params;
const toUser = await this.userService.getUserById(toUserId);
if (!toUser) {
return;
}
const type = NotificationTypeEnum.System;
const urlMeta = notificationUrlSchema.parse({
baseId: baseId,
tableId: tableId,
});
const notifyPath = this.generateNotifyPath(type, urlMeta);
this.sendCommonNotify({
path: notifyPath,
toUserId,
message,
emailConfig: {
title: 'Import result notification',
message: message,
},
});
}
async sendCommentNotify(params: {
baseId: string;
tableId: string;
recordId: string;
commentId: string;
toUserId: string;
message: string;
fromUserId: string;
}) {
const { toUserId, tableId, message, baseId, commentId, recordId, fromUserId } = params;
const toUser = await this.userService.getUserById(toUserId);
if (!toUser) {
return;
}
const type = NotificationTypeEnum.Comment;
const urlMeta = notificationUrlSchema.parse({
baseId: baseId,
tableId: tableId,
recordId: recordId,
commentId: commentId,
});
const notifyPath = this.generateNotifyPath(type, urlMeta);
this.sendCommonNotify(
{
path: notifyPath,
fromUserId,
toUserId,
message,
emailConfig: {
title: 'Record comment notification',
message: message,
},
},
type
);
}
async getNotifyList(userId: string, query: IGetNotifyListQuery): Promise<INotificationVo> {
const { notifyStates, cursor } = query;
const limit = 10;
const data = await this.prismaService.notification.findMany({
where: {
toUserId: userId,
isRead: notifyStates === NotificationStatesEnum.Read,
},
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
orderBy: {
createdTime: 'desc',
},
});
// Doesn't seem like a good way
const fromUserIds = data.map((v) => v.fromUserId);
const rawUsers = await this.prismaService.user.findMany({
select: { id: true, name: true, avatar: true },
where: { id: { in: fromUserIds } },
});
const fromUserSets = keyBy(rawUsers, 'id');
const notifications = data.map((v) => {
const notifyIcon = this.generateNotifyIcon(
v.type as NotificationTypeEnum,
v.fromUserId,
fromUserSets
);
return {
id: v.id,
notifyIcon: notifyIcon,
notifyType: v.type as NotificationTypeEnum,
url: this.mailConfig.origin + v.urlPath,
message: v.message,
isRead: v.isRead,
createdTime: v.createdTime.toISOString(),
};
});
let nextCursor: typeof cursor | undefined = undefined;
if (notifications.length > limit) {
const nextItem = notifications.pop();
nextCursor = nextItem!.id;
}
return {
notifications,
nextCursor,
};
}
private generateNotifyIcon(
notifyType: NotificationTypeEnum,
fromUserId: string,
fromUserSets: Record<string, { id: string; name: string; avatar: string | null }>
) {
const origin = this.mailConfig.origin;
switch (notifyType) {
case NotificationTypeEnum.System:
return { iconUrl: `${origin}/images/favicon/favicon.svg` };
case NotificationTypeEnum.Comment:
case NotificationTypeEnum.CollaboratorCellTag:
case NotificationTypeEnum.CollaboratorMultiRowTag: {
const { id, name, avatar } = fromUserSets[fromUserId];
return {
userId: id,
userName: name,
userAvatarUrl:
avatar && getFullStorageUrl(StorageAdapter.getBucket(UploadType.Avatar), avatar),
};
}
default:
throw assertNever(notifyType);
}
}
private generateNotifyPath(notifyType: NotificationTypeEnum, urlMeta: INotificationUrl) {
switch (notifyType) {
case NotificationTypeEnum.System: {
const { baseId, tableId } = urlMeta || {};
return `/base/${baseId}/${tableId}`;
}
case NotificationTypeEnum.Comment: {
const { baseId, tableId, recordId, commentId } = urlMeta || {};
return `/base/${baseId}/${tableId}${`?recordId=${recordId}&commentId=${commentId}`}`;
}
case NotificationTypeEnum.CollaboratorCellTag:
case NotificationTypeEnum.CollaboratorMultiRowTag: {
const { baseId, tableId, recordId } = urlMeta || {};
return `/base/${baseId}/${tableId}${recordId ? `?recordId=${recordId}` : ''}`;
}
default:
throw assertNever(notifyType);
}
}
async unreadCount(userId: string): Promise<INotificationUnreadCountVo> {
const unreadCount = await this.prismaService.notification.count({
where: {
toUserId: userId,
isRead: false,
},
});
return { unreadCount };
}
async updateNotifyStatus(
userId: string,
notificationId: string,
updateNotifyStatusRo: IUpdateNotifyStatusRo
): Promise<void> {
const { isRead } = updateNotifyStatusRo;
await this.prismaService.notification.updateMany({
where: {
id: notificationId,
toUserId: userId,
},
data: {
isRead: isRead,
},
});
}
async markAllAsRead(userId: string): Promise<void> {
await this.prismaService.notification.updateMany({
where: {
toUserId: userId,
isRead: false,
},
data: {
isRead: true,
},
});
}
private async createNotify(data: Prisma.NotificationCreateInput) {
return this.prismaService.notification.create({ data });
}
private async sendNotifyBySocket(toUserId: string, data: INotificationBuffer) {
const channel = getUserNotificationChannel(toUserId);
const presence = this.shareDbService.connect().getPresence(channel);
const localPresence = presence.create(data.notification.id);
return new Promise((resolve) => {
localPresence.submit(data, (error) => {
error && this.logger.error(error);
resolve(data);
});
});
}
private async sendNotifyByMail(to: string, emailOptions: ISendMailOptions) {
await this.mailSenderService.sendMail({
to,
...emailOptions,
});
}
}