• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

teableio / teable / 10938551787

19 Sep 2024 09:47AM UTC coverage: 84.924%. First build
10938551787

Pull #910

github

web-flow
Merge abda5a3a1 into b1eabf1c6
Pull Request #910: feat: support record comment

5565 of 5867 branches covered (94.85%)

616 of 812 new or added lines in 6 files covered. (75.86%)

36766 of 43293 relevant lines covered (84.92%)

1166.17 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

26.11
/apps/nestjs-backend/src/features/notification/notification.service.ts
1
import { Injectable, Logger } from '@nestjs/common';
4✔
2
import type { ISendMailOptions } from '@nestjs-modules/mailer';
4✔
3
import type { INotificationBuffer, INotificationUrl } from '@teable/core';
4✔
4
import {
4✔
5
  generateNotificationId,
4✔
6
  getUserNotificationChannel,
4✔
7
  NotificationStatesEnum,
4✔
8
  NotificationTypeEnum,
4✔
9
  notificationUrlSchema,
4✔
10
  userIconSchema,
4✔
11
  SYSTEM_USER_ID,
4✔
12
  assertNever,
4✔
13
} from '@teable/core';
4✔
14
import type { Prisma } from '@teable/db-main-prisma';
4✔
15
import { PrismaService } from '@teable/db-main-prisma';
4✔
16
import {
4✔
17
  UploadType,
4✔
18
  type IGetNotifyListQuery,
4✔
19
  type INotificationUnreadCountVo,
4✔
20
  type INotificationVo,
4✔
21
  type IUpdateNotifyStatusRo,
4✔
22
} from '@teable/openapi';
4✔
23
import { keyBy } from 'lodash';
4✔
24
import { IMailConfig, MailConfig } from '../../configs/mail.config';
4✔
25
import { ShareDbService } from '../../share-db/share-db.service';
4✔
26
import StorageAdapter from '../attachments/plugins/adapter';
4✔
27
import { getFullStorageUrl } from '../attachments/plugins/utils';
4✔
28
import { MailSenderService } from '../mail-sender/mail-sender.service';
4✔
29
import { UserService } from '../user/user.service';
4✔
30

4✔
31
@Injectable()
4✔
32
export class NotificationService {
4✔
33
  private readonly logger = new Logger(NotificationService.name);
393✔
34

393✔
35
  constructor(
393✔
36
    private readonly prismaService: PrismaService,
393✔
37
    private readonly shareDbService: ShareDbService,
393✔
38
    private readonly mailSenderService: MailSenderService,
393✔
39
    private readonly userService: UserService,
393✔
40
    @MailConfig() private readonly mailConfig: IMailConfig
393✔
41
  ) {}
393✔
42

393✔
43
  async sendCollaboratorNotify(params: {
393✔
44
    fromUserId: string;
22✔
45
    toUserId: string;
22✔
46
    refRecord: {
22✔
47
      baseId: string;
22✔
48
      tableId: string;
22✔
49
      tableName: string;
22✔
50
      fieldName: string;
22✔
51
      recordIds: string[];
22✔
52
    };
22✔
53
  }): Promise<void> {
22✔
54
    const { fromUserId, toUserId, refRecord } = params;
22✔
55
    const [fromUser, toUser] = await Promise.all([
22✔
56
      this.userService.getUserById(fromUserId),
22✔
57
      this.userService.getUserById(toUserId),
22✔
58
    ]);
22✔
59

22✔
60
    if (!fromUser || !toUser || fromUserId === toUserId) {
22✔
61
      return;
22✔
62
    }
22✔
63

×
64
    const notifyId = generateNotificationId();
×
65
    const emailOptions = this.mailSenderService.collaboratorCellTagEmailOptions({
×
66
      notifyId,
×
67
      fromUserName: fromUser.name,
×
68
      refRecord,
×
69
    });
×
70

×
71
    const userIcon = userIconSchema.parse({
×
72
      userId: fromUser.id,
×
73
      userName: fromUser.name,
×
74
      userAvatarUrl:
×
75
        fromUser?.avatar &&
✔
76
        getFullStorageUrl(StorageAdapter.getBucket(UploadType.Avatar), fromUser.avatar),
×
77
    });
22✔
78

22✔
79
    const urlMeta = notificationUrlSchema.parse({
22✔
80
      baseId: refRecord.baseId,
22✔
81
      tableId: refRecord.tableId,
22✔
82
      ...(refRecord.recordIds.length === 1 ? { recordId: refRecord.recordIds[0] } : {}),
22✔
83
    });
22✔
84
    const type =
22✔
85
      refRecord.recordIds.length > 1
22✔
86
        ? NotificationTypeEnum.CollaboratorMultiRowTag
×
87
        : NotificationTypeEnum.CollaboratorCellTag;
22✔
88

22✔
89
    const notifyPath = this.generateNotifyPath(type as NotificationTypeEnum, urlMeta);
22✔
90

22✔
91
    const data: Prisma.NotificationCreateInput = {
22✔
92
      id: notifyId,
22✔
93
      fromUserId,
22✔
94
      toUserId,
22✔
95
      type,
22✔
96
      message: emailOptions.notifyMessage,
22✔
97
      urlPath: notifyPath,
22✔
98
      createdBy: fromUserId,
22✔
99
    };
22✔
100
    const notifyData = await this.createNotify(data);
22✔
101

×
102
    const unreadCount = (await this.unreadCount(toUser.id)).unreadCount;
×
103

×
104
    const socketNotification = {
×
105
      notification: {
×
106
        id: notifyData.id,
×
107
        message: notifyData.message,
×
108
        notifyIcon: userIcon,
×
109
        notifyType: notifyData.type as NotificationTypeEnum,
×
110
        url: this.mailConfig.origin + notifyPath,
×
111
        isRead: false,
×
112
        createdTime: notifyData.createdTime.toISOString(),
×
113
      },
×
114
      unreadCount: unreadCount,
×
115
    };
×
116

×
117
    this.sendNotifyBySocket(toUser.id, socketNotification);
×
118

×
119
    if (toUser.notifyMeta && toUser.notifyMeta.email) {
22✔
120
      this.sendNotifyByMail(toUser.email, emailOptions);
×
121
    }
×
122
  }
22✔
123

393✔
124
  async sendCommonNotify(
393✔
125
    params: {
×
126
      path: string;
×
NEW
127
      fromUserId?: string;
×
128
      toUserId: string;
×
129
      message: string;
×
130
      emailConfig?: {
×
131
        title: string;
×
132
        message: string;
×
133
        buttonUrl?: string; // use path as default
×
134
        buttonText?: string; // use 'View' as default
×
135
      };
×
136
    },
×
137
    type = NotificationTypeEnum.System
×
138
  ) {
×
NEW
139
    const { toUserId, emailConfig, message, path, fromUserId = SYSTEM_USER_ID } = params;
×
140
    const notifyId = generateNotificationId();
×
141
    const toUser = await this.userService.getUserById(toUserId);
×
142
    if (!toUser) {
×
143
      return;
×
144
    }
×
145

×
146
    const data: Prisma.NotificationCreateInput = {
×
147
      id: notifyId,
×
NEW
148
      fromUserId: fromUserId,
×
149
      toUserId,
×
150
      type,
×
151
      urlPath: path,
×
NEW
152
      createdBy: fromUserId,
×
153
      message,
×
154
    };
×
155
    const notifyData = await this.createNotify(data);
×
156

×
157
    const unreadCount = (await this.unreadCount(toUser.id)).unreadCount;
×
158

×
NEW
159
    const rawUsers = await this.prismaService.user.findMany({
×
NEW
160
      select: { id: true, name: true, avatar: true },
×
NEW
161
      where: { id: fromUserId },
×
NEW
162
    });
×
NEW
163
    const fromUserSets = keyBy(rawUsers, 'id');
×
NEW
164

×
165
    const systemNotifyIcon = this.generateNotifyIcon(
×
166
      notifyData.type as NotificationTypeEnum,
×
NEW
167
      fromUserId,
×
NEW
168
      fromUserSets
×
169
    );
×
170

×
171
    const socketNotification = {
×
172
      notification: {
×
173
        id: notifyData.id,
×
174
        message: notifyData.message,
×
175
        notifyType: type,
×
176
        url: this.mailConfig.origin + path,
×
177
        notifyIcon: systemNotifyIcon,
×
178
        isRead: false,
×
179
        createdTime: notifyData.createdTime.toISOString(),
×
180
      },
×
181
      unreadCount: unreadCount,
×
182
    };
×
183

×
184
    this.sendNotifyBySocket(toUser.id, socketNotification);
×
185

×
186
    if (emailConfig && toUser.notifyMeta && toUser.notifyMeta.email) {
×
187
      const emailOptions = this.mailSenderService.commonEmailOptions({
×
188
        ...emailConfig,
×
189
        to: toUserId,
×
190
        buttonUrl: emailConfig.buttonUrl || this.mailConfig.origin + path,
×
191
        buttonText: emailConfig.buttonText || 'View',
×
192
      });
×
193
      this.sendNotifyByMail(toUser.email, emailOptions);
×
194
    }
×
195
  }
×
196

393✔
197
  async sendImportResultNotify(params: {
393✔
198
    tableId: string;
×
199
    baseId: string;
×
200
    toUserId: string;
×
201
    message: string;
×
202
  }) {
×
203
    const { toUserId, tableId, message, baseId } = params;
×
204
    const toUser = await this.userService.getUserById(toUserId);
×
205
    if (!toUser) {
×
206
      return;
×
207
    }
×
208
    const type = NotificationTypeEnum.System;
×
209
    const urlMeta = notificationUrlSchema.parse({
×
210
      baseId: baseId,
×
211
      tableId: tableId,
×
212
    });
×
213
    const notifyPath = this.generateNotifyPath(type, urlMeta);
×
214

×
215
    this.sendCommonNotify({
×
216
      path: notifyPath,
×
217
      toUserId,
×
218
      message,
×
219
      emailConfig: {
×
220
        title: 'Import result notification',
×
221
        message: message,
×
222
      },
×
223
    });
×
224
  }
×
225

393✔
226
  async sendCommentNotify(params: {
393✔
NEW
227
    baseId: string;
×
NEW
228
    tableId: string;
×
NEW
229
    recordId: string;
×
NEW
230
    commentId: string;
×
NEW
231
    toUserId: string;
×
NEW
232
    message: string;
×
NEW
233
    fromUserId: string;
×
NEW
234
  }) {
×
NEW
235
    const { toUserId, tableId, message, baseId, commentId, recordId, fromUserId } = params;
×
NEW
236
    const toUser = await this.userService.getUserById(toUserId);
×
NEW
237
    if (!toUser) {
×
NEW
238
      return;
×
NEW
239
    }
×
NEW
240
    const type = NotificationTypeEnum.Comment;
×
NEW
241
    const urlMeta = notificationUrlSchema.parse({
×
NEW
242
      baseId: baseId,
×
NEW
243
      tableId: tableId,
×
NEW
244
      recordId: recordId,
×
NEW
245
      commentId: commentId,
×
NEW
246
    });
×
NEW
247
    const notifyPath = this.generateNotifyPath(type, urlMeta);
×
NEW
248

×
NEW
249
    this.sendCommonNotify(
×
NEW
250
      {
×
NEW
251
        path: notifyPath,
×
NEW
252
        fromUserId,
×
NEW
253
        toUserId,
×
NEW
254
        message,
×
NEW
255
        emailConfig: {
×
NEW
256
          title: 'Record comment notification',
×
NEW
257
          message: message,
×
NEW
258
        },
×
NEW
259
      },
×
NEW
260
      type
×
NEW
261
    );
×
NEW
262
  }
×
263

393✔
264
  async getNotifyList(userId: string, query: IGetNotifyListQuery): Promise<INotificationVo> {
393✔
265
    const { notifyStates, cursor } = query;
×
266
    const limit = 10;
×
267

×
268
    const data = await this.prismaService.notification.findMany({
×
269
      where: {
×
270
        toUserId: userId,
×
271
        isRead: notifyStates === NotificationStatesEnum.Read,
×
272
      },
×
273
      take: limit + 1,
×
274
      cursor: cursor ? { id: cursor } : undefined,
×
275
      orderBy: {
×
276
        createdTime: 'desc',
×
277
      },
×
278
    });
×
279

×
280
    // Doesn't seem like a good way
×
281
    const fromUserIds = data.map((v) => v.fromUserId);
×
282
    const rawUsers = await this.prismaService.user.findMany({
×
283
      select: { id: true, name: true, avatar: true },
×
284
      where: { id: { in: fromUserIds } },
×
285
    });
×
286
    const fromUserSets = keyBy(rawUsers, 'id');
×
287

×
288
    const notifications = data.map((v) => {
×
289
      const notifyIcon = this.generateNotifyIcon(
×
290
        v.type as NotificationTypeEnum,
×
291
        v.fromUserId,
×
292
        fromUserSets
×
293
      );
×
294
      return {
×
295
        id: v.id,
×
296
        notifyIcon: notifyIcon,
×
297
        notifyType: v.type as NotificationTypeEnum,
×
298
        url: this.mailConfig.origin + v.urlPath,
×
299
        message: v.message,
×
300
        isRead: v.isRead,
×
301
        createdTime: v.createdTime.toISOString(),
×
302
      };
×
303
    });
×
304

×
305
    let nextCursor: typeof cursor | undefined = undefined;
×
306
    if (notifications.length > limit) {
×
307
      const nextItem = notifications.pop();
×
308
      nextCursor = nextItem!.id;
×
309
    }
×
310
    return {
×
311
      notifications,
×
312
      nextCursor,
×
313
    };
×
314
  }
×
315

393✔
316
  private generateNotifyIcon(
393✔
317
    notifyType: NotificationTypeEnum,
×
318
    fromUserId: string,
×
319
    fromUserSets: Record<string, { id: string; name: string; avatar: string | null }>
×
320
  ) {
×
321
    const origin = this.mailConfig.origin;
×
322

×
323
    switch (notifyType) {
×
324
      case NotificationTypeEnum.System:
×
325
        return { iconUrl: `${origin}/images/favicon/favicon.svg` };
×
NEW
326
      case NotificationTypeEnum.Comment:
×
327
      case NotificationTypeEnum.CollaboratorCellTag:
×
328
      case NotificationTypeEnum.CollaboratorMultiRowTag: {
×
329
        const { id, name, avatar } = fromUserSets[fromUserId];
×
330

×
331
        return {
×
332
          userId: id,
×
333
          userName: name,
×
334
          userAvatarUrl:
×
335
            avatar && getFullStorageUrl(StorageAdapter.getBucket(UploadType.Avatar), avatar),
×
336
        };
×
337
      }
×
NEW
338
      default:
×
NEW
339
        throw assertNever(notifyType);
×
340
    }
×
341
  }
×
342

393✔
343
  private generateNotifyPath(notifyType: NotificationTypeEnum, urlMeta: INotificationUrl) {
393✔
344
    switch (notifyType) {
×
345
      case NotificationTypeEnum.System: {
×
346
        const { baseId, tableId } = urlMeta || {};
×
347
        return `/base/${baseId}/${tableId}`;
×
348
      }
×
NEW
349
      case NotificationTypeEnum.Comment: {
×
NEW
350
        const { baseId, tableId, recordId, commentId } = urlMeta || {};
×
NEW
351

×
NEW
352
        return `/base/${baseId}/${tableId}${`?recordId=${recordId}&commentId=${commentId}`}`;
×
NEW
353
      }
×
354
      case NotificationTypeEnum.CollaboratorCellTag:
×
355
      case NotificationTypeEnum.CollaboratorMultiRowTag: {
×
356
        const { baseId, tableId, recordId } = urlMeta || {};
×
357

×
358
        return `/base/${baseId}/${tableId}${recordId ? `?recordId=${recordId}` : ''}`;
×
359
      }
×
NEW
360
      default:
×
NEW
361
        throw assertNever(notifyType);
×
362
    }
×
363
  }
×
364

393✔
365
  async unreadCount(userId: string): Promise<INotificationUnreadCountVo> {
393✔
366
    const unreadCount = await this.prismaService.notification.count({
×
367
      where: {
×
368
        toUserId: userId,
×
369
        isRead: false,
×
370
      },
×
371
    });
×
372
    return { unreadCount };
×
373
  }
×
374

393✔
375
  async updateNotifyStatus(
393✔
376
    userId: string,
×
377
    notificationId: string,
×
378
    updateNotifyStatusRo: IUpdateNotifyStatusRo
×
379
  ): Promise<void> {
×
380
    const { isRead } = updateNotifyStatusRo;
×
381

×
382
    await this.prismaService.notification.updateMany({
×
383
      where: {
×
384
        id: notificationId,
×
385
        toUserId: userId,
×
386
      },
×
387
      data: {
×
388
        isRead: isRead,
×
389
      },
×
390
    });
×
391
  }
×
392

393✔
393
  async markAllAsRead(userId: string): Promise<void> {
393✔
394
    await this.prismaService.notification.updateMany({
×
395
      where: {
×
396
        toUserId: userId,
×
397
        isRead: false,
×
398
      },
×
399
      data: {
×
400
        isRead: true,
×
401
      },
×
402
    });
×
403
  }
×
404

393✔
405
  private async createNotify(data: Prisma.NotificationCreateInput) {
393✔
406
    return this.prismaService.notification.create({ data });
×
407
  }
×
408

393✔
409
  private async sendNotifyBySocket(toUserId: string, data: INotificationBuffer) {
393✔
410
    const channel = getUserNotificationChannel(toUserId);
×
411

×
412
    const presence = this.shareDbService.connect().getPresence(channel);
×
413
    const localPresence = presence.create(data.notification.id);
×
414

×
415
    return new Promise((resolve) => {
×
416
      localPresence.submit(data, (error) => {
×
417
        error && this.logger.error(error);
×
418
        resolve(data);
×
419
      });
×
420
    });
×
421
  }
×
422

393✔
423
  private async sendNotifyByMail(to: string, emailOptions: ISendMailOptions) {
393✔
424
    await this.mailSenderService.sendMail({
×
425
      to,
×
426
      ...emailOptions,
×
427
    });
×
428
  }
×
429
}
393✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc