1import { Inject, Injectable } from "@nestjs/common";
2import { PrismaService } from "../../common/prisma.service.js";
3import { AuditService } from "../../common/audit.service.js";
4import { ApiError } from "../../common/errors.js";
5import { assertCan, assertRole } from "../../lib/authorize.js";
6import { between } from "../../lib/positions.js";
7import { EVENTS } from "../../constants.js";
8import { EventBus } from "../../common/event-bus.js";
9import type { CreateTeamInput, UpdateTeamInput } from "./dto.js";
10
11const TEAM_SELECT = {
12 id: true, name: true, slug: true, description: true, color: true,
13 iconUrl: true, isPrivate: true, position: true, createdAt: true
14} as const;
15
16@Injectable()
17export class TeamsService {
18 constructor(
19 @Inject(PrismaService) private readonly prisma: PrismaService,
20 @Inject(AuditService) private readonly audit: AuditService,
21 @Inject(EventBus) private readonly events: EventBus
22 ) {}
23
24 private async getTeam(workspaceId: string, teamId: string) {
25 const team = await this.prisma.team.findFirst({ where: { id: teamId, workspaceId }, select: TEAM_SELECT });
26 if (!team) throw ApiError.notFound();
27 return team;
28 }
29
30 async list(workspaceId: string, userId: string) {
31 await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
32 const teams = await this.prisma.team.findMany({
33 where: { workspaceId },
34 select: { ...TEAM_SELECT, _count: { select: { members: true } } },
35 orderBy: { position: "asc" }
36 });
37 return { data: teams.map((t) => ({ ...t, memberCount: t._count.members })) };
38 }
39
40 async create(workspaceId: string, userId: string, input: CreateTeamInput, req: any) {
41 await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]);
42 const slug = (input.slug ?? slugify(input.name)).slice(0, 40);
43 const last = await this.prisma.team.findFirst({
44 where: { workspaceId },
45 orderBy: { position: "desc" },
46 select: { position: true }
47 });
48 let team;
49 try {
50 team = await this.prisma.team.create({
51 data: {
52 workspaceId,
53 name: input.name,
54 slug,
55 description: input.description,
56 color: input.color,
57 iconUrl: input.iconUrl,
58 isPrivate: input.isPrivate ?? false,
59 position: between(last?.position)
60 },
61 select: TEAM_SELECT
62 });
63 } catch {
64 throw new ApiError("CONFLICT", 409, "Team slug already exists in this workspace.", [
65 { field: "slug", issue: "already_exists" }
66 ]);
67 }
68 await this.audit.audit(req, req?.auth, {
69 action: "team.created",
70 resourceType: "team",
71 resourceId: team.id,
72 workspaceId
73 });
74 return team;
75 }
76
77 async get(workspaceId: string, teamId: string, userId: string) {
78 await assertCan(this.prisma, { workspaceId, userId, resource: { teamId } }, "VIEW");
79 const team = await this.getTeam(workspaceId, teamId);
80
81
82 const members = await this.prisma.teamMember.findMany({
83 where: { teamId },
84 include: { workspaceMember: { include: { user: { select: { id: true, email: true, name: true, avatarUrl: true } } } } }
85 });
86 return { ...team, members: members.map((m) => ({ userId: m.userId, email: m.workspaceMember.user.email, name: m.workspaceMember.user.name, avatarUrl: m.workspaceMember.user.avatarUrl })) };
87 }
88
89 async update(workspaceId: string, teamId: string, userId: string, input: UpdateTeamInput, req: any) {
90 await assertCan(this.prisma, { workspaceId, userId, resource: { teamId } }, "EDIT");
91 const team = await this.prisma.team.update({
92 where: { id: teamId },
93 data: {
94 ...(input.name !== undefined ? { name: input.name } : {}),
95 ...(input.slug !== undefined ? { slug: input.slug } : {}),
96 ...(input.description !== undefined ? { description: input.description } : {}),
97 ...(input.color !== undefined ? { color: input.color } : {}),
98 ...(input.iconUrl !== undefined ? { iconUrl: input.iconUrl } : {}),
99 ...(input.isPrivate !== undefined ? { isPrivate: input.isPrivate } : {})
100 },
101 select: TEAM_SELECT
102 });
103 await this.audit.audit(req, req?.auth, {
104 action: "team.updated",
105 resourceType: "team",
106 resourceId: teamId,
107 workspaceId
108 });
109 return team;
110 }
111
112 async remove(workspaceId: string, teamId: string, userId: string, req: any) {
113 await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]);
114 await this.getTeam(workspaceId, teamId);
115 await this.prisma.team.delete({ where: { id: teamId } });
116 await this.audit.audit(req, req?.auth, {
117 action: "team.deleted",
118 resourceType: "team",
119 resourceId: teamId,
120 workspaceId
121 });
122 }
123
124
125
126 async addMember(workspaceId: string, teamId: string, userId: string, memberUserId: string, req: any) {
127 await assertCan(this.prisma, { workspaceId, userId, resource: { teamId } }, "EDIT");
128 const member = await this.prisma.workspaceMember.findUnique({
129 where: { workspaceId_userId: { workspaceId, userId: memberUserId } }
130 });
131 if (!member) throw ApiError.notFound("User is not a member of this workspace.");
132 await this.prisma.teamMember.upsert({
133 where: { teamId_userId: { teamId, userId: memberUserId } },
134 update: {},
135 create: { teamId, workspaceMemberId: member.id, userId: memberUserId, role: member.role as never }
136 });
137 await this.audit.audit(req, req?.auth, {
138 action: "team.member_added",
139 resourceType: "team_member",
140 resourceId: `${teamId}:${memberUserId}`,
141 workspaceId
142 });
143
144 await this.audit.audit(req, req?.auth, {
145 action: EVENTS.PERMISSION_CHANGED,
146 resourceType: "team_member",
147 resourceId: `${teamId}:${memberUserId}`,
148 workspaceId,
149 metadata: { change: "add", targetId: memberUserId, teamId }
150 });
151 await this.events.publish(EVENTS.PERMISSION_CHANGED, { workspaceId, userId: memberUserId, teamId });
152 return { teamId, userId: memberUserId };
153 }
154
155 async removeMember(workspaceId: string, teamId: string, userId: string, memberUserId: string, req: any) {
156 await assertCan(this.prisma, { workspaceId, userId, resource: { teamId } }, "EDIT");
157 const tm = await this.prisma.teamMember.findUnique({
158 where: { teamId_userId: { teamId, userId: memberUserId } }
159 });
160 if (!tm) throw ApiError.notFound("Not a team member.");
161 await this.prisma.teamMember.delete({ where: { id: tm.id } });
162 await this.audit.audit(req, req?.auth, {
163 action: "team.member_removed",
164 resourceType: "team_member",
165 resourceId: `${teamId}:${memberUserId}`,
166 workspaceId
167 });
168
169 await this.audit.audit(req, req?.auth, {
170 action: EVENTS.PERMISSION_CHANGED,
171 resourceType: "team_member",
172 resourceId: `${teamId}:${memberUserId}`,
173 workspaceId,
174 metadata: { change: "remove", targetId: memberUserId, teamId }
175 });
176 await this.events.publish(EVENTS.PERMISSION_CHANGED, { workspaceId, userId: memberUserId, teamId, removed: true });
177 }
178}
179
180function slugify(name: string): string {
181 return name
182 .toLowerCase()
183 .replace(/[^a-z0-9]+/g, "-")
184 .replace(/^-+|-+$/g, "")
185 .slice(0, 40) || "team";
186}
187