TTeamDesk 学习手册

src/server/services/team.ts

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

ts
import { TRPCError } from '@trpc/server';
import type { PrismaClient } from '@/generated/prisma/client';
import type { MemberRole } from '@/shared/team';
import { requireAdmin, requireMembership } from './access';

export function createTeamService(db: PrismaClient, userId: string) {
  return {
    async list() {
      const members = await db.membership.findMany({
        where: { userId },
        include: { team: true },
        orderBy: { createdAt: 'asc' },
      });
      return members.map(({ team, role }) => ({
        id: team.id,
        name: team.name,
        role,
      }));
    },
    async create(name: string) {
      // 嵌套写入是一个事务:团队与首个管理员必须同时创建成功,不能留下无人管理的团队。
      const team = await db.team.create({
        data: { name, members: { create: { userId, role: 'ADMIN' } } },
      });
      return { id: team.id, name: team.name };
    },
    async members(teamId: string) {
      await requireMembership(db, userId, teamId);
      const members = await db.membership.findMany({
        where: { teamId },
        select: {
          userId: true,
          role: true,
          user: { select: { name: true, email: true } },
        },
        orderBy: { createdAt: 'asc' },
      });
      return members.map(({ user, ...member }) => ({ ...member, ...user }));
    },
    async add(input: { teamId: string; email: string; role: MemberRole }) {
      return db.$transaction(async (tx) => {
        // 串行处理同一团队的成员变更,防止两个管理员同时把对方降级,导致无人管理。
        // SQL 参数通过模板绑定,不能使用字符串拼接来处理用户输入。
        await tx.$queryRaw`SELECT id FROM "Team" WHERE id = ${input.teamId}::uuid FOR UPDATE`;
        await requireAdmin(tx, userId, input.teamId);
        const user = await tx.user.findUnique({
          where: { email: input.email },
        });
        if (!user)
          throw new TRPCError({
            code: 'NOT_FOUND',
            message: '该邮箱尚未注册,请对方先注册账号',
          });
        if (
          await tx.membership.findUnique({
            where: { teamId_userId: { teamId: input.teamId, userId: user.id } },
          })
        )
          throw new TRPCError({
            code: 'CONFLICT',
            message: '该用户已经是团队成员',
          });
        await tx.membership.create({
          data: { teamId: input.teamId, userId: user.id, role: input.role },
        });
        return { success: true };
      });
    },
    async change(input: { teamId: string; userId: string; role?: MemberRole }) {
      return db.$transaction(async (tx) => {
        await tx.$queryRaw`SELECT id FROM "Team" WHERE id = ${input.teamId}::uuid FOR UPDATE`;
        const actor = await requireAdmin(tx, userId, input.teamId);
        const target = await tx.membership.findUnique({
          where: {
            teamId_userId: { teamId: input.teamId, userId: input.userId },
          },
        });
        if (!target)
          throw new TRPCError({ code: 'NOT_FOUND', message: '成员不存在' });
        if (
          target.role === 'ADMIN' &&
          input.role !== 'ADMIN' &&
          (await tx.membership.count({
            where: { teamId: input.teamId, role: 'ADMIN' },
          })) <= 1
        )
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: '团队必须保留至少一位管理员',
          });
        // 被移除或降为客户后不再承担工单;同一事务内清空分配并递增版本,让旧页面感知变更。
        if (!input.role || input.role === 'CUSTOMER') {
          const targetUser = await tx.user.findUniqueOrThrow({
            where: { id: target.userId },
            select: { name: true },
          });
          const updated = await tx.ticket.updateManyAndReturn({
            where: { teamId: input.teamId, assigneeMembershipId: target.id },
            data: { assigneeMembershipId: null, version: { increment: 1 } },
            select: { id: true, version: true },
          });
          // 自动收回分配也是实际工单变更;和成员变更一起提交,失败时全部回滚。
          // 使用 createMany 批量记录,避免负责人关联多张工单时逐条往返数据库。
          if (updated.length) {
            const actorUser = await tx.user.findUniqueOrThrow({
              where: { id: userId },
              select: { name: true },
            });
            await tx.ticketEvent.createMany({
              data: updated.map((ticket) => ({
                ticketId: ticket.id,
                teamId: input.teamId,
                version: ticket.version,
                kind: 'ASSIGNEE_RELEASED' as const,
                actorUserId: userId,
                actorName: actorUser.name,
                actorRole: actor.role,
                fromAssigneeName: targetUser.name,
                reason: input.role
                  ? '负责人角色变为客户,自动解除分配'
                  : '负责人被移出团队,自动解除分配',
              })),
            });
          }
        }
        if (input.role)
          await tx.membership.update({
            where: { id: target.id },
            data: { role: input.role },
          });
        else await tx.membership.delete({ where: { id: target.id } });
        return { success: true };
      });
    },
  };
}

搜索教程

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

阅读设置

外观
正文字号

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

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