import { Inject, Injectable } from "@nestjs/common"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { ApiError } from "../../common/errors.js"; import { assertCan, assertRole } from "../../lib/authorize.js"; import { between } from "../../lib/positions.js"; import { EVENTS } from "../../constants.js"; import { EventBus } from "../../common/event-bus.js"; import type { CreateTeamInput, UpdateTeamInput } from "./dto.js"; const TEAM_SELECT = { id: true, name: true, slug: true, description: true, color: true, iconUrl: true, isPrivate: true, position: true, createdAt: true } as const; @Injectable() export class TeamsService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService, @Inject(EventBus) private readonly events: EventBus ) {} private async getTeam(workspaceId: string, teamId: string) { const team = await this.prisma.team.findFirst({ where: { id: teamId, workspaceId }, select: TEAM_SELECT }); if (!team) throw ApiError.notFound(); return team; } async list(workspaceId: string, userId: string) { await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); const teams = await this.prisma.team.findMany({ where: { workspaceId }, select: { ...TEAM_SELECT, _count: { select: { members: true } } }, orderBy: { position: "asc" } }); return { data: teams.map((t) => ({ ...t, memberCount: t._count.members })) }; } async create(workspaceId: string, userId: string, input: CreateTeamInput, req: any) { await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]); const slug = (input.slug ?? slugify(input.name)).slice(0, 40); const last = await this.prisma.team.findFirst({ where: { workspaceId }, orderBy: { position: "desc" }, select: { position: true } }); let team; try { team = await this.prisma.team.create({ data: { workspaceId, name: input.name, slug, description: input.description, color: input.color, iconUrl: input.iconUrl, isPrivate: input.isPrivate ?? false, position: between(last?.position) }, select: TEAM_SELECT }); } catch { throw new ApiError("CONFLICT", 409, "Team slug already exists in this workspace.", [ { field: "slug", issue: "already_exists" } ]); } await this.audit.audit(req, req?.auth, { action: "team.created", resourceType: "team", resourceId: team.id, workspaceId }); return team; } async get(workspaceId: string, teamId: string, userId: string) { await assertCan(this.prisma, { workspaceId, userId, resource: { teamId } }, "VIEW"); const team = await this.getTeam(workspaceId, teamId); // TeamMember has no direct `user` relation — the member's user is // reached via the WorkspaceMember row (TeamMember.workspaceMember.user). const members = await this.prisma.teamMember.findMany({ where: { teamId }, include: { workspaceMember: { include: { user: { select: { id: true, email: true, name: true, avatarUrl: true } } } } } }); return { ...team, members: members.map((m) => ({ userId: m.userId, email: m.workspaceMember.user.email, name: m.workspaceMember.user.name, avatarUrl: m.workspaceMember.user.avatarUrl })) }; } async update(workspaceId: string, teamId: string, userId: string, input: UpdateTeamInput, req: any) { await assertCan(this.prisma, { workspaceId, userId, resource: { teamId } }, "EDIT"); const team = await this.prisma.team.update({ where: { id: teamId }, data: { ...(input.name !== undefined ? { name: input.name } : {}), ...(input.slug !== undefined ? { slug: input.slug } : {}), ...(input.description !== undefined ? { description: input.description } : {}), ...(input.color !== undefined ? { color: input.color } : {}), ...(input.iconUrl !== undefined ? { iconUrl: input.iconUrl } : {}), ...(input.isPrivate !== undefined ? { isPrivate: input.isPrivate } : {}) }, select: TEAM_SELECT }); await this.audit.audit(req, req?.auth, { action: "team.updated", resourceType: "team", resourceId: teamId, workspaceId }); return team; } async remove(workspaceId: string, teamId: string, userId: string, req: any) { await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]); await this.getTeam(workspaceId, teamId); await this.prisma.team.delete({ where: { id: teamId } }); await this.audit.audit(req, req?.auth, { action: "team.deleted", resourceType: "team", resourceId: teamId, workspaceId }); } // ---------- membership ---------- async addMember(workspaceId: string, teamId: string, userId: string, memberUserId: string, req: any) { await assertCan(this.prisma, { workspaceId, userId, resource: { teamId } }, "EDIT"); const member = await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId: memberUserId } } }); if (!member) throw ApiError.notFound("User is not a member of this workspace."); await this.prisma.teamMember.upsert({ where: { teamId_userId: { teamId, userId: memberUserId } }, update: {}, create: { teamId, workspaceMemberId: member.id, userId: memberUserId, role: member.role as never } }); await this.audit.audit(req, req?.auth, { action: "team.member_added", resourceType: "team_member", resourceId: `${teamId}:${memberUserId}`, workspaceId }); // TASK-0133 audit catalog: adding a team member IS a permission change. await this.audit.audit(req, req?.auth, { action: EVENTS.PERMISSION_CHANGED, resourceType: "team_member", resourceId: `${teamId}:${memberUserId}`, workspaceId, metadata: { change: "add", targetId: memberUserId, teamId } }); await this.events.publish(EVENTS.PERMISSION_CHANGED, { workspaceId, userId: memberUserId, teamId }); return { teamId, userId: memberUserId }; } async removeMember(workspaceId: string, teamId: string, userId: string, memberUserId: string, req: any) { await assertCan(this.prisma, { workspaceId, userId, resource: { teamId } }, "EDIT"); const tm = await this.prisma.teamMember.findUnique({ where: { teamId_userId: { teamId, userId: memberUserId } } }); if (!tm) throw ApiError.notFound("Not a team member."); await this.prisma.teamMember.delete({ where: { id: tm.id } }); await this.audit.audit(req, req?.auth, { action: "team.member_removed", resourceType: "team_member", resourceId: `${teamId}:${memberUserId}`, workspaceId }); // TASK-0133 audit catalog: removing a team member IS a permission change. await this.audit.audit(req, req?.auth, { action: EVENTS.PERMISSION_CHANGED, resourceType: "team_member", resourceId: `${teamId}:${memberUserId}`, workspaceId, metadata: { change: "remove", targetId: memberUserId, teamId } }); await this.events.publish(EVENTS.PERMISSION_CHANGED, { workspaceId, userId: memberUserId, teamId, removed: true }); } } function slugify(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 40) || "team"; }