teableio/teable

View on GitHub
apps/nestjs-backend/src/features/comment/comment-open-api.service.ts

Summary

Maintainability
C
1 day
Test Coverage
import {
  Injectable,
  Logger,
  ForbiddenException,
  BadGatewayException,
  BadRequestException,
} from '@nestjs/common';
import { generateCommentId, getCommentChannel, getTableCommentChannel } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import type {
  ICreateCommentRo,
  ICommentVo,
  IUpdateCommentRo,
  IGetCommentListQueryRo,
  ICommentContent,
  IGetRecordsRo,
  IParagraphCommentContent,
} from '@teable/openapi';
import { CommentNodeType, CommentPatchType } from '@teable/openapi';
import { uniq, omit } from 'lodash';
import { ClsService } from 'nestjs-cls';
import { ShareDbService } from '../../share-db/share-db.service';
import type { IClsStore } from '../../types/cls';
import { NotificationService } from '../notification/notification.service';
import { RecordService } from '../record/record.service';

@Injectable()
export class CommentOpenApiService {
  private logger = new Logger(CommentOpenApiService.name);
  constructor(
    private readonly notificationService: NotificationService,
    private readonly recordService: RecordService,
    private readonly prismaService: PrismaService,
    private readonly cls: ClsService<IClsStore>,
    private readonly shareDbService: ShareDbService
  ) {}

  async getCommentDetail(commentId: string) {
    const rawComment = await this.prismaService.comment.findFirst({
      where: {
        id: commentId,
        deletedTime: null,
      },
      select: {
        id: true,
        content: true,
        createdBy: true,
        createdTime: true,
        lastModifiedTime: true,
        deletedTime: true,
        quoteId: true,
        reaction: true,
      },
    });

    if (!rawComment) {
      return null;
    }

    return {
      ...rawComment,
      reaction: rawComment.reaction ? JSON.parse(rawComment?.reaction) : null,
      content: rawComment?.content ? JSON.parse(rawComment?.content) : null,
    } as ICommentVo;
  }

  async getCommentList(
    tableId: string,
    recordId: string,
    getCommentListQuery: IGetCommentListQueryRo
  ) {
    const { cursor, take = 20, direction = 'forward', includeCursor = true } = getCommentListQuery;

    if (take > 1000) {
      throw new BadRequestException(`${take} exceed the max count comment list count 1000`);
    }

    const takeWithDirection = direction === 'forward' ? -(take + 1) : take + 1;

    const rawComments = await this.prismaService.comment.findMany({
      where: {
        recordId,
        tableId,
        deletedTime: null,
      },
      orderBy: [{ createdTime: 'asc' }],
      take: takeWithDirection,
      skip: cursor ? (includeCursor ? 0 : 1) : 0,
      cursor: cursor ? { id: cursor } : undefined,
      select: {
        id: true,
        content: true,
        createdBy: true,
        createdTime: true,
        lastModifiedTime: true,
        quoteId: true,
        reaction: true,
      },
    });

    const hasNextPage = rawComments.length > take;

    const nextCursor = hasNextPage
      ? direction === 'forward'
        ? rawComments.shift()?.id
        : rawComments.pop()?.id
      : null;

    const parsedComments = rawComments
      .sort((a, b) => a.createdTime.getTime() - b.createdTime.getTime())
      .map(
        (comment) =>
          ({
            ...comment,
            content: comment.content ? JSON.parse(comment.content) : null,
            reaction: comment.reaction ? JSON.parse(comment.reaction) : null,
          }) as ICommentVo
      );

    return {
      comments: parsedComments,
      nextCursor,
    };
  }

  async createComment(tableId: string, recordId: string, createCommentRo: ICreateCommentRo) {
    const id = generateCommentId();
    const result = await this.prismaService.comment.create({
      data: {
        id,
        tableId,
        recordId,
        content: JSON.stringify(createCommentRo.content),
        createdBy: this.cls.get('user.id'),
        quoteId: createCommentRo.quoteId,
        lastModifiedTime: null,
      },
    });

    await this.sendCommentNotify(tableId, recordId, id, {
      content: result.content,
      quoteId: result.quoteId,
    });

    this.sendCommentPatch(tableId, recordId, CommentPatchType.CreateComment, result);
    this.sendTableCommentPatch(tableId, recordId, CommentPatchType.CreateComment);

    return {
      ...result,
      content: result.content ? JSON.parse(result.content) : null,
    };
  }

  async updateComment(
    tableId: string,
    recordId: string,
    commentId: string,
    updateCommentRo: IUpdateCommentRo
  ) {
    const result = await this.prismaService.comment
      .update({
        where: {
          id: commentId,
          createdBy: this.cls.get('user.id'),
        },
        data: {
          content: JSON.stringify(updateCommentRo.content),
          lastModifiedTime: new Date().toISOString(),
        },
      })
      .catch(() => {
        throw new ForbiddenException('You have no permission to delete this comment');
      });

    this.sendCommentPatch(tableId, recordId, CommentPatchType.UpdateComment, result);
    await this.sendCommentNotify(tableId, recordId, commentId, {
      quoteId: result.quoteId,
      content: result.content,
    });
  }

  async deleteComment(tableId: string, recordId: string, commentId: string) {
    await this.prismaService.comment
      .update({
        where: {
          id: commentId,
          createdBy: this.cls.get('user.id'),
        },
        data: {
          deletedTime: new Date().toISOString(),
        },
      })
      .catch(() => {
        throw new ForbiddenException('You have no permission to delete this comment');
      });

    this.sendCommentPatch(tableId, recordId, CommentPatchType.CreateReaction, { id: commentId });
    this.sendTableCommentPatch(tableId, recordId, CommentPatchType.DeleteComment);
  }

  async deleteCommentReaction(
    tableId: string,
    recordId: string,
    commentId: string,
    reactionRo: { reaction: string }
  ) {
    const commentRaw = await this.getCommentReactionById(commentId);
    const { reaction } = reactionRo;
    let data: ICommentVo['reaction'] = [];

    if (commentRaw && commentRaw.reaction) {
      const emojis = JSON.parse(commentRaw.reaction) as NonNullable<ICommentVo['reaction']>;
      const index = emojis.findIndex((item) => item.reaction === reaction);
      if (index > -1) {
        const newUser = emojis[index].user.filter((item) => item !== this.cls.get('user.id'));
        if (newUser.length === 0) {
          emojis.splice(index, 1);
        } else {
          emojis.splice(index, 1, {
            reaction,
            user: newUser,
          });
        }
        data = [...emojis];
      }
    }

    const result = await this.prismaService.comment
      .update({
        where: {
          id: commentId,
        },
        data: {
          reaction: data.length ? JSON.stringify(data) : null,
          lastModifiedTime: commentRaw?.lastModifiedTime,
        },
      })
      .catch((e) => {
        throw new BadGatewayException(e);
      });

    this.sendCommentPatch(tableId, recordId, CommentPatchType.DeleteReaction, result);
  }

  async createCommentReaction(
    tableId: string,
    recordId: string,
    commentId: string,
    reactionRo: { reaction: string }
  ) {
    const commentRaw = await this.getCommentReactionById(commentId);
    const { reaction } = reactionRo;
    let data: ICommentVo['reaction'];

    if (commentRaw && commentRaw.reaction) {
      const emojis = JSON.parse(commentRaw.reaction) as NonNullable<ICommentVo['reaction']>;
      const index = emojis.findIndex((item) => item.reaction === reaction);
      if (index > -1) {
        emojis.splice(index, 1, {
          reaction,
          user: uniq([...emojis[index].user, this.cls.get('user.id')]),
        });
      } else {
        emojis.push({
          reaction,
          user: [this.cls.get('user.id')],
        });
      }
      data = [...emojis];
    } else {
      data = [
        {
          reaction,
          user: [this.cls.get('user.id')],
        },
      ];
    }

    const result = await this.prismaService.comment
      .update({
        where: {
          id: commentId,
        },
        data: {
          reaction: JSON.stringify(data),
          lastModifiedTime: commentRaw?.lastModifiedTime,
        },
      })
      .catch((e) => {
        throw new BadGatewayException(e);
      });

    await this.sendCommentPatch(tableId, recordId, CommentPatchType.CreateReaction, result);
    await this.sendCommentNotify(tableId, recordId, commentId, {
      quoteId: result.quoteId,
      content: result.content,
    });
  }

  async getSubscribeDetail(tableId: string, recordId: string) {
    return await this.prismaService.commentSubscription
      .findUniqueOrThrow({
        where: {
          // eslint-disable-next-line
          tableId_recordId: {
            tableId,
            recordId,
          },
        },
        select: {
          tableId: true,
          recordId: true,
          createdBy: true,
        },
      })
      .catch(() => {
        return null;
      });
  }

  async subscribeComment(tableId: string, recordId: string) {
    await this.prismaService.commentSubscription.create({
      data: {
        tableId,
        recordId,
        createdBy: this.cls.get('user.id'),
      },
    });
  }

  async unsubscribeComment(tableId: string, recordId: string) {
    await this.prismaService.commentSubscription.delete({
      where: {
        // eslint-disable-next-line
        tableId_recordId: {
          tableId,
          recordId,
        },
      },
    });
  }

  async getTableCommentCount(tableId: string, query: IGetRecordsRo) {
    const docResult = await this.recordService.getDocIdsByQuery(tableId, query);
    const recordsId = docResult.ids;

    const result = await this.prismaService.comment.groupBy({
      by: ['recordId'],
      where: {
        recordId: {
          in: recordsId,
        },
        deletedTime: null,
      },
      _count: {
        ['recordId']: true,
      },
    });

    return result.map(({ _count: { recordId: count }, recordId }) => ({
      recordId,
      count,
    }));
  }

  async getRecordCommentCount(tableId: string, recordId: string) {
    const result = await this.prismaService.comment.count({
      where: {
        tableId,
        recordId,
        deletedTime: null,
      },
    });

    return {
      count: result,
    };
  }

  private async getCommentReactionById(commentId: string) {
    return await this.prismaService.comment.findFirst({
      where: {
        id: commentId,
      },
      select: {
        reaction: true,
        lastModifiedTime: true,
      },
    });
  }

  private async sendCommentNotify(
    tableId: string,
    recordId: string,
    commentId: string,
    notifyVo: { quoteId: string | null; content: string | null }
  ) {
    const { quoteId, content } = notifyVo;
    const { id: fromUserId, name: fromUserName } = this.cls.get('user');
    const relativeUsers: string[] = [];

    if (quoteId) {
      const { createdBy: quoteCommentCreator } =
        (await this.prismaService.comment.findUnique({
          where: {
            id: quoteId,
          },
          select: {
            createdBy: true,
          },
        })) || {};
      quoteCommentCreator && relativeUsers.push(quoteCommentCreator);
    }

    const mentionUsers = this.getMentionUserByContent(content);

    if (mentionUsers.length) {
      relativeUsers.push(...mentionUsers);
    }

    const { baseId, name: tableName } =
      (await this.prismaService.tableMeta.findFirst({
        where: {
          id: tableId,
        },
        select: {
          baseId: true,
          name: true,
        },
      })) || {};

    const { id: fieldId } =
      (await this.prismaService.field.findFirst({
        where: {
          tableId,
          isPrimary: true,
        },
        select: {
          id: true,
        },
      })) || {};

    if (!baseId || !fieldId) {
      return;
    }

    const { name: baseName } =
      (await this.prismaService.base.findFirst({
        where: {
          id: baseId,
        },
        select: {
          name: true,
        },
      })) || {};

    const recordName = await this.recordService.getCellValue(tableId, recordId, fieldId);

    const notifyUsers = await this.prismaService.commentSubscription.findMany({
      where: {
        tableId,
        recordId,
      },
      select: {
        createdBy: true,
      },
    });

    const subscribeUsersIds = Array.from(
      new Set([...notifyUsers.map(({ createdBy }) => createdBy), ...relativeUsers])
    ).filter((userId) => userId !== fromUserId);

    const message = `${fromUserName} made a commented on ${recordName ? recordName : 'a record'} in ${tableName} ${baseName ? `in ${baseName}` : ''}`;

    subscribeUsersIds.forEach((userId) => {
      this.notificationService.sendCommentNotify({
        baseId,
        tableId,
        recordId,
        commentId,
        toUserId: userId,
        message,
        fromUserId,
      });
    });
  }

  private getMentionUserByContent(commentContentRaw: string | null) {
    if (!commentContentRaw) {
      return [];
    }

    const commentContent = JSON.parse(commentContentRaw) as ICommentContent;

    return commentContent
      .filter(
        // so strange that infer automatically error
        (comment): comment is IParagraphCommentContent => comment.type === CommentNodeType.Paragraph
      )
      .flatMap((paragraphNode) => paragraphNode.children)
      .filter((lineNode) => lineNode.type === CommentNodeType.Mention)
      .map((mentionNode) => mentionNode.value) as string[];
  }

  private createCommentPresence(tableId: string, recordId: string) {
    const channel = getCommentChannel(tableId, recordId);
    const presence = this.shareDbService.connect().getPresence(channel);
    return presence.create(channel);
  }

  private sendCommentPatch(
    tableId: string,
    recordId: string,
    type: CommentPatchType,
    data: Record<string, unknown>
  ) {
    const localPresence = this.createCommentPresence(tableId, recordId);

    let finalData = omit(data, ['tableId', 'recordId']);

    if (
      [
        CommentPatchType.CreateComment,
        CommentPatchType.CreateReaction,
        CommentPatchType.UpdateComment,
        CommentPatchType.DeleteReaction,
      ].includes(type)
    ) {
      const { content, reaction } = finalData;
      finalData = {
        ...finalData,
        content: content ? JSON.parse(content as string) : content,
        reaction: reaction ? JSON.parse(reaction as string) : reaction,
      };
    }

    localPresence.submit(
      {
        type: type,
        data: finalData,
      },
      (error) => {
        error && this.logger.error('Comment patch presence error: ', error);
      }
    );
  }

  private sendTableCommentPatch(tableId: string, recordId: string, type: CommentPatchType) {
    const channel = getTableCommentChannel(tableId);
    const presence = this.shareDbService.connect().getPresence(channel);
    const localPresence = presence.create(channel);

    localPresence.submit(
      {
        type,
        data: {
          recordId,
        },
      },
      (error) => {
        error && this.logger.error('Comment patch presence error: ', error);
      }
    );
  }
}