Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 102 additions & 65 deletions apps/nestjs-backend/src/features/notification/notification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ type INotifyEmailConfig = {
buttonText?: string | ILocalization<I18nPath>;
};

function toArray<T>(value?: T | T[]): T[] {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}

const notificationListLimit = 10;

const notificationListSelect = {
Expand Down Expand Up @@ -63,6 +68,7 @@ export class NotificationService {
[NotificationTypeEnum.CollaboratorMultiRowTag]: MailType.CollaboratorMultiRowTag,
[NotificationTypeEnum.Comment]: MailType.Common,
[NotificationTypeEnum.ExportBase]: MailType.ExportBase,
[NotificationTypeEnum.AdminNotice]: MailType.System,
};
constructor(
private readonly prismaService: PrismaService,
Expand Down Expand Up @@ -304,91 +310,118 @@ export class NotificationService {

async sendCommonNotify(
params: {
path: string;
path?: string;
fromUserId?: string;
toUserId: string;
toUserId?: string | string[];
toEmail?: string | string[];
message: string | ILocalization<I18nPath>;
severity?: NotificationSeverityEnum;
emailConfig?: INotifyEmailConfig;
},
type = NotificationTypeEnum.System
) {
const { toUserId, emailConfig, path, fromUserId = SYSTEM_USER_ID } = params;
const notifyId = generateNotificationId();
const toUser = await this.userService.getUserById(toUserId);
if (!toUser) {
return;
): Promise<{
sentCount: number;
invalidUserIds?: string[];
invalidEmails?: string[];
}> {
const { emailConfig, path = '', fromUserId = SYSTEM_USER_ID } = params;
const ids = toArray(params.toUserId);
const emails = toArray(params.toEmail);

const toUsers = await this.userService.getUsersByIdsOrEmails({ ids, emails });

const invalidUserIds = ids.length
? ids.filter((id) => !toUsers.some((u) => u.id === id))
: undefined;
const invalidEmails = emails.length
? emails.filter((e) => !toUsers.some((u) => u.email.toLowerCase() === e.toLowerCase()))
: undefined;

if (toUsers.length === 0) {
return { sentCount: 0, invalidUserIds, invalidEmails };
}

const severity = params.severity ?? this.getNotificationSeverity(type);
const messageI18n = this.getMessageI18n(params.message);
const data: Prisma.NotificationCreateInput = {
id: notifyId,
fromUserId: fromUserId,
toUserId,
type,
urlPath: path,
createdBy: fromUserId,
message: this.getMessage(params.message, 'en'),
messageI18n,
severity,
};
const notifyData = await this.createNotify(data);

const unreadCount = (await this.unreadCount(toUser.id)).unreadCount;
const messageEn = this.getMessage(params.message, 'en');

const rawUsers = await this.prismaService.user.findMany({
select: { id: true, name: true, avatar: true },
where: { id: fromUserId },
});
const fromUserSets = keyBy(rawUsers, 'id');
const notifyIcon = this.generateNotifyIcon(type, fromUserId, fromUserSets);

const systemNotifyIcon = this.generateNotifyIcon(
notifyData.type as NotificationTypeEnum,
const createdTime = new Date();
const notifyRecords = toUsers.map((toUser) => ({
id: generateNotificationId(),
fromUserId,
fromUserSets
);

const socketNotification = {
notification: {
id: notifyData.id,
message: notifyData.message,
messageI18n: notifyData.messageI18n,
notifyType: type,
url: path,
notifyIcon: systemNotifyIcon,
severity,
isRead: false,
createdTime: notifyData.createdTime.toISOString(),
},
unreadCount: unreadCount,
};

this.sendNotifyBySocket(toUser.id, socketNotification);
toUserId: toUser.id,
type,
urlPath: path,
createdBy: fromUserId,
message: messageEn,
messageI18n,
severity,
createdTime,
}));

if (emailConfig && toUser.notifyMeta && toUser.notifyMeta.email) {
const lang = this.getUserLang(toUser.lang);
const emailOptions = await this.mailSenderService.commonEmailOptions({
...emailConfig,
title: this.getMessage(emailConfig.title, lang),
message: this.getMessage(emailConfig.message, lang),
to: toUserId,
buttonUrl: emailConfig.buttonUrl || this.mailConfig.origin + path,
buttonText: emailConfig.buttonText
? this.getMessage(emailConfig.buttonText, lang)
: this.i18n.t('common.email.templates.notify.buttonText'),
});
this.mailSenderService.sendMail(
{
to: toUser.email,
...emailOptions,
const toUserIdList = toUsers.map((u) => u.id);
const unreadCounts = await this.prismaService.notification.groupBy({
by: ['toUserId'],
where: { toUserId: { in: toUserIdList }, isRead: false },
_count: { _all: true },
});
const unreadCountMap = new Map(unreadCounts.map((r) => [r.toUserId, r._count._all]));

await this.prismaService.notification.createMany({ data: notifyRecords });

const notifyById = keyBy(notifyRecords, 'toUserId');
for (const toUser of toUsers) {
const record = notifyById[toUser.id];
const unreadCount = (unreadCountMap.get(toUser.id) ?? 0) + 1;

this.sendNotifyBySocket(toUser.id, {
notification: {
id: record.id,
message: messageEn,
messageI18n,
notifyType: type,
url: path,
notifyIcon: notifyIcon,
severity,
isRead: false,
createdTime: createdTime.toISOString(),
},
{
type: this.mailTypeMap[type],
transporterName: MailTransporterType.Notify,
}
);
unreadCount,
});

if (emailConfig && toUser.notifyMeta && toUser.notifyMeta.email) {
const lang = this.getUserLang(toUser.lang);
const emailOptions = await this.mailSenderService.commonEmailOptions({
...emailConfig,
title: this.getMessage(emailConfig.title, lang),
message: this.getMessage(emailConfig.message, lang),
to: toUser.id,
buttonUrl: emailConfig.buttonUrl || this.mailConfig.origin + path,
buttonText: emailConfig.buttonText
? this.getMessage(emailConfig.buttonText, lang)
: this.i18n.t('common.email.templates.notify.buttonText'),
});
this.mailSenderService.sendMail(
{
to: toUser.email,
...emailOptions,
},
{
type: this.mailTypeMap[type],
transporterName: MailTransporterType.Notify,
}
);
}
}

return { sentCount: toUsers.length, invalidUserIds, invalidEmails };
}

async sendImportResultNotify(params: {
Expand Down Expand Up @@ -574,7 +607,7 @@ export class NotificationService {
id: v.id,
notifyIcon: notifyIcon,
notifyType: v.type as NotificationTypeEnum,
url: this.mailConfig.origin + v.urlPath,
url: v.urlPath ? this.mailConfig.origin + v.urlPath : '',
message: v.message,
messageI18n: v.messageI18n,
severity: this.getNotificationSeverity(v.type as NotificationTypeEnum, v.severity),
Expand All @@ -594,6 +627,7 @@ export class NotificationService {
switch (notifyType) {
case NotificationTypeEnum.System:
case NotificationTypeEnum.ExportBase:
case NotificationTypeEnum.AdminNotice:
return { iconUrl: `${origin}/images/favicon/favicon.svg` };
case NotificationTypeEnum.Comment:
case NotificationTypeEnum.CollaboratorCellTag:
Expand Down Expand Up @@ -628,6 +662,7 @@ export class NotificationService {
case NotificationTypeEnum.CollaboratorMultiRowTag:
case NotificationTypeEnum.ExportBase:
case NotificationTypeEnum.System:
case NotificationTypeEnum.AdminNotice:
return NotificationSeverityEnum.Info;
default:
throw assertNever(notifyType);
Expand Down Expand Up @@ -655,6 +690,8 @@ export class NotificationService {
const { downloadUrl } = urlMeta || {};
return downloadUrl as string;
}
case NotificationTypeEnum.AdminNotice:
return '';
default:
throw assertNever(notifyType);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { Controller, Delete, Get, Param, Patch, Post, Query, Res } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Res } from '@nestjs/common';
import {
adminSendNotificationRoSchema,
type IAdminSendNotificationRo,
type IAdminSendNotificationVo,
} from '@teable/openapi';
import { Response } from 'express';
import { ZodValidationPipe } from '../../../zod.validation.pipe';
import { Permissions } from '../../auth/decorators/permissions.decorator';
import { AdminOpenApiService } from './admin-open-api.service';

Expand Down Expand Up @@ -37,4 +43,11 @@ export class AdminOpenApiController {
async deletePerformanceCache(@Query('key') key?: string) {
return await this.adminService.deletePerformanceCache(key);
}

@Post('notification')
async sendNotification(
@Body(new ZodValidationPipe(adminSendNotificationRoSchema)) ro: IAdminSendNotificationRo
): Promise<IAdminSendNotificationVo> {
return this.adminService.sendAdminNotification(ro);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { MulterModule } from '@nestjs/platform-express';
import multer from 'multer';
import { AttachmentsCropModule } from '../../attachments/attachments-crop.module';
import { StorageModule } from '../../attachments/plugins/storage.module';
import { NotificationModule } from '../../notification/notification.module';
import { AdminOpenApiController } from './admin-open-api.controller';
import { AdminOpenApiService } from './admin-open-api.service';

Expand All @@ -13,6 +14,7 @@ import { AdminOpenApiService } from './admin-open-api.service';
storage: multer.diskStorage({}),
}),
StorageModule,
NotificationModule,
],
controllers: [AdminOpenApiController],
exports: [AdminOpenApiService],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@ import {
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import { NotificationSeverityEnum, NotificationTypeEnum } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import type { IAdminSendNotificationRo } from '@teable/openapi';
import { PluginStatus, UploadType } from '@teable/openapi';
import { Response } from 'express';
import { Knex } from 'knex';
import { InjectModel } from 'nest-knexjs';
import { ClsService } from 'nestjs-cls';
import { PerformanceCacheService } from '../../../performance-cache';
import type { IClsStore } from '../../../types/cls';
import { Timing } from '../../../utils/timing';
import { AttachmentsCropQueueProcessor } from '../../attachments/attachments-crop.processor';
import StorageAdapter from '../../attachments/plugins/adapter';
import { NotificationService } from '../../notification/notification.service';

@Injectable()
export class AdminOpenApiService {
Expand All @@ -24,7 +29,9 @@ export class AdminOpenApiService {
private readonly prismaService: PrismaService,
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
private readonly attachmentsCropQueueProcessor: AttachmentsCropQueueProcessor,
private readonly performanceCacheService: PerformanceCacheService
private readonly performanceCacheService: PerformanceCacheService,
private readonly notificationService: NotificationService,
private readonly cls: ClsService<IClsStore>
) {}

async publishPlugin(pluginId: string) {
Expand Down Expand Up @@ -178,4 +185,20 @@ export class AdminOpenApiService {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await this.performanceCacheService.del(key as any);
}

async sendAdminNotification(ro: IAdminSendNotificationRo) {
const fromUserId = this.cls.get('user.id');
const { message, severity, userIds, emails } = ro;

return this.notificationService.sendCommonNotify(
{
fromUserId,
toUserId: userIds,
toEmail: emails,
message,
severity,
},
NotificationTypeEnum.AdminNotice
);
}
}
17 changes: 17 additions & 0 deletions apps/nestjs-backend/src/features/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,23 @@ export class UserService {
);
}

async getUsersByIdsOrEmails(params: { ids?: string[]; emails?: string[] }) {
const { ids = [], emails = [] } = params;
const conditions = [];
if (ids.length > 0) conditions.push({ id: { in: ids } });
if (emails.length > 0) conditions.push({ email: { in: emails.map((e) => e.toLowerCase()) } });
if (conditions.length === 0) return [];

const users = await this.prismaService.user.findMany({
where: { OR: conditions, deletedTime: null },
});
return users.map((u) => ({
...u,
avatar: u.avatar && getPublicFullStorageUrl(u.avatar),
notifyMeta: u.notifyMeta ? (JSON.parse(u.notifyMeta) as IUserNotifyMeta) : null,
}));
}

async getUserByEmail(email: string) {
return await this.prismaService.txClient().user.findUnique({
where: { email: email.toLowerCase(), deletedTime: null },
Expand Down
Loading
Loading