TTeamDesk 学习手册

src/server/services/collaboration.ts

此代码来自构建时的仓库快照。修改源码后重新构建即可更新。

ts
import { TRPCError } from '@trpc/server';
import type { Prisma, PrismaClient } from '@/generated/prisma/client';
import { requireMembership, requireTicketAccess } from './access';
import { recordTicketEvent } from './ticket-events';

const replySelect = {
  id: true,
  content: true,
  authorId: true,
  authorName: true,
  authorRole: true,
  createdAt: true,
} as const;
function serializeReply(
  reply: Prisma.TicketReplyGetPayload<{ select: typeof replySelect }>,
) {
  return { ...reply, createdAt: reply.createdAt.toISOString() };
}
export function createCollaborationService(
  db: PrismaClient,
  userId: string,
  teamId: string,
) {
  return {
    async assignees() {
      const member = await requireMembership(db, userId, teamId);
      if (member.role === 'CUSTOMER')
        throw new TRPCError({ code: 'FORBIDDEN', message: '客户不能分配工单' });
      return db.membership.findMany({
        where: { teamId, role: { in: ['ADMIN', 'AGENT'] } },
        select: { id: true, userId: true, user: { select: { name: true } } },
        orderBy: { createdAt: 'asc' },
      });
    },
    async assign(input: {
      id: string;
      assigneeId: string | null;
      version: number;
    }) {
      return db.$transaction(async (tx) => {
        // 成员变更也持有同一把团队锁,防止验证候选人后,对方立刻被移除或降级。
        await tx.$queryRaw`SELECT id FROM "Team" WHERE id = ${teamId}::uuid FOR UPDATE`;
        const { member, ticket } = await requireTicketAccess(
          tx,
          userId,
          teamId,
          input.id,
        );
        if (member.role === 'CUSTOMER')
          throw new TRPCError({
            code: 'FORBIDDEN',
            message: '仅管理员和客服可以分配工单',
          });
        if (ticket.status === 'CLOSED')
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: '工单已关闭,不能再修改分配',
          });
        if (ticket.version !== input.version)
          throw new TRPCError({
            code: 'CONFLICT',
            message: '工单已更新,请刷新后重新分配',
          });
        // 重复选择当前负责人不产生新版本和假操作记录。
        if (ticket.assigneeMembershipId === input.assigneeId)
          return { success: true };
        let toAssigneeName: string | undefined;
        if (input.assigneeId) {
          const target = await tx.membership.findFirst({
            where: {
              id: input.assigneeId,
              teamId,
              role: { in: ['ADMIN', 'AGENT'] },
            },
            include: { user: { select: { name: true } } },
          });
          if (!target)
            throw new TRPCError({
              code: 'BAD_REQUEST',
              message: '负责人必须是本团队的客服或管理员',
            });
          toAssigneeName = target.user.name;
        }
        const previous = ticket.assigneeMembershipId
          ? await tx.membership.findUnique({
              where: { id: ticket.assigneeMembershipId },
              select: { user: { select: { name: true } } },
            })
          : null;
        // 更新时仍带团队和版本条件,避免未来调用方式变更后出现跨团队写入。
        const result = await tx.ticket.updateMany({
          where: { id: input.id, teamId, version: input.version },
          data: {
            assigneeMembershipId: input.assigneeId,
            version: { increment: 1 },
          },
        });
        if (!result.count)
          throw new TRPCError({
            code: 'CONFLICT',
            message: '工单已更新,请刷新后重试',
          });
        await recordTicketEvent(tx, {
          teamId,
          ticketId: input.id,
          version: ticket.version + 1,
          kind: input.assigneeId ? 'ASSIGNED' : 'UNASSIGNED',
          actorUserId: userId,
          actorRole: member.role,
          fromAssigneeName: previous?.user.name,
          toAssigneeName,
        });
        return { success: true };
      });
    },
    async replies(input: { id: string; page: number; pageSize: number }) {
      return db.$transaction(
        async (tx) => {
          await requireTicketAccess(tx, userId, teamId, input.id);
          // 一次返回固定页数,避免对话变长后读取全部历史;排序加 id 保证同一时间的记录也稳定。
          const where = { teamId, ticketId: input.id };
          const items = await tx.ticketReply.findMany({
            where,
            select: replySelect,
            orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
            skip: (input.page - 1) * input.pageSize,
            take: input.pageSize,
          });
          const total = await tx.ticketReply.count({ where });
          return { items: items.map(serializeReply), total };
        },
        { isolationLevel: 'RepeatableRead' },
      );
    },
    async reply(input: {
      id: string;
      content: string;
      clientRequestId: string;
    }) {
      return db.$transaction(async (tx) => {
        await tx.$queryRaw`SELECT id FROM "Team" WHERE id = ${teamId}::uuid FOR UPDATE`;
        const { ticket, member } = await requireTicketAccess(
          tx,
          userId,
          teamId,
          input.id,
        );
        // 网络超时不代表写入失败。相同请求编号重试时返回已保存的回复,避免产生重复消息。
        const existing = await tx.ticketReply.findFirst({
          where: {
            teamId,
            ticketId: input.id,
            authorId: userId,
            clientRequestId: input.clientRequestId,
          },
          select: replySelect,
        });
        if (existing) {
          if (existing.content !== input.content)
            throw new TRPCError({
              code: 'CONFLICT',
              message: '该请求编号已使用,请刷新后重试',
            });
          return serializeReply(existing);
        }
        if (ticket.status === 'CLOSED')
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: '工单已关闭,不能继续回复',
          });
        const author = await tx.user.findUniqueOrThrow({
          where: { id: userId },
          select: { name: true },
        });
        // 作者和角色来自会话与数据库,不接受客户端填写;姓名快照让历史回复保持可读。
        const reply = await tx.ticketReply.create({
          data: {
            ticketId: input.id,
            teamId,
            authorId: userId,
            authorName: author.name,
            authorRole: member.role,
            content: input.content,
            clientRequestId: input.clientRequestId,
          },
          select: replySelect,
        });
        // 回复和工单更新时间在同一事务提交;任一步失败都会回滚,不产生半完成记录。
        const updated = await tx.ticket.update({
          where: { id: input.id, teamId },
          data: { updatedAt: new Date(), version: { increment: 1 } },
        });
        await recordTicketEvent(tx, {
          teamId,
          ticketId: input.id,
          version: updated.version,
          kind: 'REPLIED',
          actorUserId: userId,
          actorRole: member.role,
          replyId: reply.id,
        });
        return serializeReply(reply);
      });
    },
  };
}

搜索教程

输入关键词,搜索全部章节。

阅读设置

外观
正文字号

从一张工单出发,读懂数据如何流转。

设置与进度仅保存在当前浏览器。