Add named teams and per-user data visibility (own/team/org) with settings UI and org module.

This commit is contained in:
2026-09-26 17:15:09 +03:30
parent 499d13d1df
commit 1b160c8cd3
17 changed files with 1097 additions and 28 deletions
+60
View File
@@ -0,0 +1,60 @@
import {
IsArray,
IsIn,
IsOptional,
IsString,
MaxLength,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateTeamDto {
@IsString()
@MinLength(2)
@MaxLength(80)
name!: string;
@IsOptional()
@IsString()
@MaxLength(300)
description?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
memberIds?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
managerIds?: string[];
}
export class UpdateTeamDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(80)
name?: string;
@IsOptional()
@IsString()
@MaxLength(300)
description?: string;
}
class TeamMemberInputDto {
@IsString()
userId!: string;
@IsIn(['member', 'manager'])
role!: 'member' | 'manager';
}
export class SetTeamMembersDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => TeamMemberInputDto)
members!: TeamMemberInputDto[];
}
+70
View File
@@ -0,0 +1,70 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { CreateTeamDto, SetTeamMembersDto, UpdateTeamDto } from './dto';
import { TeamsService } from './teams.service';
@Controller('teams')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('teams')
export class TeamsController {
constructor(private readonly teams: TeamsService) {}
@Get()
list(@CurrentUser() user: { organizationId: string }) {
return this.teams.list(user.organizationId);
}
@Post()
@Roles('owner', 'admin')
create(
@CurrentUser() user: { organizationId: string },
@Body() dto: CreateTeamDto,
) {
return this.teams.create(user.organizationId, dto);
}
@Patch(':id')
@Roles('owner', 'admin')
update(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: UpdateTeamDto,
) {
return this.teams.update(user.organizationId, id, dto);
}
@Put(':id/members')
@Roles('owner', 'admin')
setMembers(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: SetTeamMembersDto,
) {
return this.teams.setMembers(user.organizationId, id, dto);
}
@Delete(':id')
@Roles('owner', 'admin')
async remove(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
await this.teams.remove(user.organizationId, id);
return { ok: true };
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { TeamsController } from './teams.controller';
import { TeamsService } from './teams.service';
@Module({
imports: [AuthModule, BillingModule],
controllers: [TeamsController],
providers: [TeamsService],
exports: [TeamsService],
})
export class TeamsModule {}
+244
View File
@@ -0,0 +1,244 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
CreateTeamRequest,
SetTeamMembersRequest,
TeamDto,
TeamMemberDto,
TeamMemberRole,
UpdateTeamRequest,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class TeamsService {
constructor(private readonly prisma: PrismaService) {}
async list(organizationId: string): Promise<TeamDto[]> {
const rows = await this.prisma.team.findMany({
where: { organizationId },
include: {
members: {
include: {
user: {
select: {
id: true,
fullName: true,
avatarStorageKey: true,
},
},
},
orderBy: [{ role: 'asc' }, { user: { fullName: 'asc' } }],
},
},
orderBy: { name: 'asc' },
});
return rows.map((row) => this.toDto(row));
}
async create(
organizationId: string,
dto: CreateTeamRequest,
): Promise<TeamDto> {
const name = dto.name?.trim();
if (!name) throw new BadRequestException('نام تیم الزامی است');
const managerIds = new Set((dto.managerIds ?? []).filter(Boolean));
const memberIds = [
...new Set([
...(dto.memberIds ?? []).filter(Boolean),
...managerIds,
]),
];
if (memberIds.length) {
await this.assertMembersInOrg(organizationId, memberIds);
}
try {
const row = await this.prisma.team.create({
data: {
organizationId,
name,
description: dto.description?.trim() ?? '',
members: memberIds.length
? {
create: memberIds.map((userId) => ({
userId,
role: managerIds.has(userId) ? 'manager' : 'member',
})),
}
: undefined,
},
include: {
members: {
include: {
user: {
select: {
id: true,
fullName: true,
avatarStorageKey: true,
},
},
},
},
},
});
return this.toDto(row);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('تیمی با این نام از قبل وجود دارد');
}
throw e;
}
}
async update(
organizationId: string,
id: string,
dto: UpdateTeamRequest,
): Promise<TeamDto> {
await this.ensureTeam(organizationId, id);
const data: Prisma.TeamUpdateInput = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('نام تیم الزامی است');
data.name = name;
}
if (dto.description !== undefined) {
data.description = dto.description.trim();
}
try {
await this.prisma.team.update({ where: { id }, data });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('تیمی با این نام از قبل وجود دارد');
}
throw e;
}
return this.get(organizationId, id);
}
async remove(organizationId: string, id: string): Promise<void> {
await this.ensureTeam(organizationId, id);
await this.prisma.team.delete({ where: { id } });
}
async setMembers(
organizationId: string,
id: string,
dto: SetTeamMembersRequest,
): Promise<TeamDto> {
await this.ensureTeam(organizationId, id);
const members = dto.members ?? [];
const userIds = [...new Set(members.map((m) => m.userId).filter(Boolean))];
if (userIds.length) {
await this.assertMembersInOrg(organizationId, userIds);
}
const byUser = new Map<string, TeamMemberRole>();
for (const m of members) {
if (!m.userId) continue;
byUser.set(m.userId, m.role === 'manager' ? 'manager' : 'member');
}
await this.prisma.$transaction([
this.prisma.teamMember.deleteMany({ where: { teamId: id } }),
...(byUser.size
? [
this.prisma.teamMember.createMany({
data: [...byUser.entries()].map(([userId, role]) => ({
teamId: id,
userId,
role,
})),
}),
]
: []),
]);
return this.get(organizationId, id);
}
private async get(organizationId: string, id: string): Promise<TeamDto> {
const row = await this.prisma.team.findFirst({
where: { id, organizationId },
include: {
members: {
include: {
user: {
select: {
id: true,
fullName: true,
avatarStorageKey: true,
},
},
},
orderBy: [{ role: 'asc' }, { user: { fullName: 'asc' } }],
},
},
});
if (!row) throw new NotFoundException('تیم پیدا نشد');
return this.toDto(row);
}
private async ensureTeam(organizationId: string, id: string) {
const row = await this.prisma.team.findFirst({
where: { id, organizationId },
select: { id: true },
});
if (!row) throw new NotFoundException('تیم پیدا نشد');
}
private async assertMembersInOrg(
organizationId: string,
userIds: string[],
) {
const count = await this.prisma.membership.count({
where: {
organizationId,
userId: { in: userIds },
isActive: true,
},
});
if (count !== userIds.length) {
throw new BadRequestException('برخی کاربران عضو فعال این محیط نیستند');
}
}
private toDto(row: {
id: string;
name: string;
description: string;
createdAt: Date;
updatedAt: Date;
members: Array<{
role: string;
user: {
id: string;
fullName: string;
avatarStorageKey: string | null;
};
}>;
}): TeamDto {
const members: TeamMemberDto[] = row.members.map((m) => ({
userId: m.user.id,
fullName: m.user.fullName,
role: m.role === 'manager' ? 'manager' : 'member',
hasAvatar: Boolean(m.user.avatarStorageKey),
}));
return {
id: row.id,
name: row.name,
description: row.description,
memberCount: members.length,
members,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}