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
+2
View File
@@ -38,6 +38,7 @@ import { AuditLogsModule } from './audit-logs/audit-logs.module';
import { SavedViewsModule } from './saved-views/saved-views.module';
import { PublicApiModule } from './public-api/public-api.module';
import { SmsCampaignsModule } from './sms-campaigns/sms-campaigns.module';
import { TeamsModule } from './teams/teams.module';
@Module({
imports: [
@@ -83,6 +84,7 @@ import { SmsCampaignsModule } from './sms-campaigns/sms-campaigns.module';
SavedViewsModule,
PublicApiModule,
SmsCampaignsModule,
TeamsModule,
],
})
export class AppModule {}
+46 -1
View File
@@ -2,8 +2,11 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type { DataVisibility } from '@mento/shared';
import { BillingService } from '../billing/billing.service';
import { normalizeDataVisibility } from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service';
import { canViewAllRecords } from '@mento/shared';
export type JwtPayload = {
sub: string;
@@ -50,16 +53,58 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
isActive: true,
organization: { isActive: true },
},
select: { organizationId: true, role: true },
select: { organizationId: true, role: true, dataVisibility: true },
});
if (!membership) {
throw new UnauthorizedException();
}
await this.billing.requireActiveForOrganization(membership.organizationId);
const dataVisibility = normalizeDataVisibility(membership.dataVisibility);
const visibleUserIds = await this.resolveVisibleUserIds(
membership.organizationId,
user.id,
membership.role,
dataVisibility,
);
return {
userId: user.id,
organizationId: membership.organizationId,
role: membership.role,
dataVisibility,
visibleUserIds,
};
}
private async resolveVisibleUserIds(
organizationId: string,
userId: string,
role: string,
dataVisibility: DataVisibility,
): Promise<string[] | null> {
if (canViewAllRecords(role) || dataVisibility === 'org') {
return null;
}
if (dataVisibility === 'own') {
return [userId];
}
const memberships = await this.prisma.teamMember.findMany({
where: {
userId,
team: { organizationId },
},
select: { teamId: true },
});
if (!memberships.length) return [userId];
const peers = await this.prisma.teamMember.findMany({
where: {
teamId: { in: memberships.map((m) => m.teamId) },
},
select: { userId: true },
});
return [...new Set([userId, ...peers.map((p) => p.userId)])];
}
}
+62 -20
View File
@@ -1,4 +1,4 @@
import type { UserRole } from '@mento/shared';
import type { DataVisibility, UserRole } from '@mento/shared';
import { canViewAllRecords } from '@mento/shared';
import type { Prisma } from '@prisma/client';
@@ -6,39 +6,73 @@ export type Actor = {
userId: string;
role: string;
organizationId: string;
/** own | team | org — از Membership */
dataVisibility?: DataVisibility;
/**
* شناسه کاربرانی که پرونده‌شان برای این Actor قابل‌مشاهده است.
* اگر undefined باشد فقط خود کاربر؛ اگر null یعنی همه (elevated/org).
*/
visibleUserIds?: string[] | null;
};
export function elevated(role: string | null | undefined): boolean {
return canViewAllRecords(role);
}
export function seesAllRecords(
actor: Pick<Actor, 'role' | 'dataVisibility' | 'visibleUserIds'>,
): boolean {
if (elevated(actor.role)) return true;
if (actor.dataVisibility === 'org') return true;
if (actor.visibleUserIds === null) return true;
return false;
}
function scopedOwnerIds(
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
): string[] | null {
if (seesAllRecords(actor)) return null;
const ids = actor.visibleUserIds?.length
? actor.visibleUserIds
: [actor.userId];
return [...new Set(ids.filter(Boolean))];
}
/** فیلتر مالکیت برای Deal / Quote / Invoice / Person / Company */
export function ownerWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): { ownerId: string } | Record<string, never> {
if (elevated(actor.role)) return {};
return { ownerId: actor.userId };
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> {
const ids = scopedOwnerIds(actor);
if (!ids) return {};
if (ids.length === 1) return { ownerId: ids[0]! };
return { ownerId: { in: ids } };
}
/** فعالیت‌ها بر اساس مسئول */
export function assigneeWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): { assigneeId: string } | Record<string, never> {
if (elevated(actor.role)) return {};
return { assigneeId: actor.userId };
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
):
| { assigneeId: string }
| { assigneeId: { in: string[] } }
| Record<string, never> {
const ids = scopedOwnerIds(actor);
if (!ids) return {};
if (ids.length === 1) return { assigneeId: ids[0]! };
return { assigneeId: { in: ids } };
}
/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت */
/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت در محدوده دید */
export function noteAccessWhere(
actor: Pick<Actor, 'userId' | 'role'>,
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
): Prisma.NoteWhereInput {
if (elevated(actor.role)) return {};
const ids = scopedOwnerIds(actor);
if (!ids) return {};
return {
OR: [
{ authorId: actor.userId },
{ deal: { ownerId: actor.userId } },
{ person: { ownerId: actor.userId } },
{ company: { ownerId: actor.userId } },
{ authorId: { in: ids } },
{ deal: { ownerId: { in: ids } } },
{ person: { ownerId: { in: ids } } },
{ company: { ownerId: { in: ids } } },
],
};
}
@@ -53,13 +87,14 @@ export function resolveOwnerId(
return actor.userId;
}
/** تیکت: مسئول یا ثبت‌کننده */
/** تیکت: مسئول یا ثبت‌کننده در محدوده دید */
export function ticketAccessWhere(
actor: Pick<Actor, 'userId' | 'role'>,
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
): Prisma.TicketWhereInput {
if (elevated(actor.role)) return {};
const ids = scopedOwnerIds(actor);
if (!ids) return {};
return {
OR: [{ assigneeId: actor.userId }, { reporterId: actor.userId }],
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
};
}
@@ -68,3 +103,10 @@ export function canReassignOwner(role: string | null | undefined): boolean {
}
export type OrgRole = UserRole;
export function normalizeDataVisibility(
value: string | null | undefined,
): DataVisibility {
if (value === 'team' || value === 'org' || value === 'own') return value;
return 'own';
}
+5 -2
View File
@@ -403,13 +403,16 @@ export class SalesService {
actor: Actor,
filterUserId?: string,
): Prisma.SalesGoalWhereInput {
if (elevated(actor.role)) {
if (elevated(actor.role) || actor.dataVisibility === 'org' || actor.visibleUserIds === null) {
if (filterUserId === 'org') return { userId: null };
if (filterUserId) return { userId: filterUserId };
return {};
}
const ids = actor.visibleUserIds?.length
? actor.visibleUserIds
: [actor.userId];
return {
OR: [{ userId: actor.userId }, { userId: null }],
OR: [{ userId: { in: ids } }, { userId: null }],
};
}
+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(),
};
}
}
+5
View File
@@ -67,3 +67,8 @@ export class UpdateUserCommissionDto {
@Max(100)
commissionPercent!: number;
}
export class UpdateUserVisibilityDto {
@IsIn(['own', 'team', 'org'])
dataVisibility!: 'own' | 'team' | 'org';
}
+16
View File
@@ -18,6 +18,7 @@ import {
UpdateUserCommissionDto,
UpdateUserRoleDto,
UpdateUserStatusDto,
UpdateUserVisibilityDto,
} from './dto';
import { UsersService } from './users.service';
@@ -79,6 +80,21 @@ export class UsersController {
);
}
@Patch(':id/visibility')
@Roles('owner', 'admin')
updateVisibility(
@CurrentUser() user: { organizationId: string; role: UserRole },
@Param('id') id: string,
@Body() dto: UpdateUserVisibilityDto,
) {
return this.usersService.updateVisibility(
user.organizationId,
user.role,
id,
dto.dataVisibility,
);
}
@Patch(':id/role')
@Roles('owner', 'admin')
updateRole(
+42 -1
View File
@@ -5,7 +5,13 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { InviteUserRequest, OrgUserDto, UserRole } from '@mento/shared';
import type {
DataVisibility,
InviteUserRequest,
OrgUserDto,
UserRole,
} from '@mento/shared';
import { DATA_VISIBILITIES } from '@mento/shared';
import * as bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
import { normalizeMobile } from '../auth/auth.service';
@@ -32,6 +38,7 @@ type MembershipRow = {
orgRoleId: string | null;
isActive: boolean;
commissionPercent: { toString(): string } | number | null;
dataVisibility?: string | null;
createdAt: Date;
orgRole: { id: string; name: string; color: string } | null;
user: {
@@ -155,6 +162,34 @@ export class UsersService {
return this.dtoOf(organizationId, targetId);
}
async updateVisibility(
organizationId: string,
actorRole: UserRole,
targetId: string,
dataVisibility: DataVisibility,
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('اجازه تغییر سطح دید را ندارید');
}
if (!DATA_VISIBILITIES.includes(dataVisibility)) {
throw new BadRequestException('سطح دید نامعتبر است');
}
const target = await this.memberships.findUserInOrgById(
organizationId,
targetId,
);
if (target.role === 'owner' || target.role === 'admin') {
throw new BadRequestException(
'مالک و مدیر همیشه همه پرونده‌ها را می‌بینند',
);
}
await this.prisma.membership.update({
where: { id: target.id },
data: { dataVisibility },
});
return this.dtoOf(organizationId, targetId);
}
async updateRole(
organizationId: string,
actor: { userId: string; role: UserRole },
@@ -326,6 +361,12 @@ export class UsersService {
lastLoginAt: row.user.lastLoginAt ? row.user.lastLoginAt.toISOString() : null,
hasAvatar: Boolean(row.user.avatarStorageKey),
commissionPercent: Number(row.commissionPercent ?? 0),
dataVisibility:
row.role === 'owner' || row.role === 'admin'
? 'org'
: DATA_VISIBILITIES.includes(row.dataVisibility as DataVisibility)
? (row.dataVisibility as DataVisibility)
: 'own',
createdAt: row.createdAt.toISOString(),
};
}