Add named teams and per-user data visibility (own/team/org) with settings UI and org module.
This commit is contained in:
@@ -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[];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user