src/server/services/team.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 };
});
},
};
}