From 1b160c8cd38206fc6b3fd1712e44a613282655c9 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 26 Sep 2026 17:15:09 +0330 Subject: [PATCH] Add named teams and per-user data visibility (own/team/org) with settings UI and org module. --- .../migration.sql | 49 +++ apps/api/prisma/schema.prisma | 34 ++ apps/api/src/app.module.ts | 2 + apps/api/src/auth/jwt.strategy.ts | 47 ++- apps/api/src/common/record-scope.ts | 82 +++- apps/api/src/sales/sales.service.ts | 7 +- apps/api/src/teams/dto.ts | 60 +++ apps/api/src/teams/teams.controller.ts | 70 ++++ apps/api/src/teams/teams.module.ts | 13 + apps/api/src/teams/teams.service.ts | 244 ++++++++++++ apps/api/src/users/dto.ts | 5 + apps/api/src/users/users.controller.ts | 16 + apps/api/src/users/users.service.ts | 43 ++- .../web/src/components/TeamsSettingsPanel.vue | 362 ++++++++++++++++++ apps/web/src/lib/nav-hubs.ts | 9 + apps/web/src/views/TeamSettingsView.vue | 23 +- packages/shared/src/index.ts | 59 ++- 17 files changed, 1097 insertions(+), 28 deletions(-) create mode 100644 apps/api/prisma/migrations/20260926180000_teams_data_visibility/migration.sql create mode 100644 apps/api/src/teams/dto.ts create mode 100644 apps/api/src/teams/teams.controller.ts create mode 100644 apps/api/src/teams/teams.module.ts create mode 100644 apps/api/src/teams/teams.service.ts create mode 100644 apps/web/src/components/TeamsSettingsPanel.vue diff --git a/apps/api/prisma/migrations/20260926180000_teams_data_visibility/migration.sql b/apps/api/prisma/migrations/20260926180000_teams_data_visibility/migration.sql new file mode 100644 index 0000000..665f113 --- /dev/null +++ b/apps/api/prisma/migrations/20260926180000_teams_data_visibility/migration.sql @@ -0,0 +1,49 @@ +-- AlterTable +ALTER TABLE "Membership" ADD COLUMN "dataVisibility" TEXT NOT NULL DEFAULT 'own'; + +-- CreateTable +CREATE TABLE "Team" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamMember" ( + "id" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'member', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Team_organizationId_idx" ON "Team"("organizationId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_organizationId_name_key" ON "Team"("organizationId", "name"); + +-- CreateIndex +CREATE INDEX "TeamMember_userId_idx" ON "TeamMember"("userId"); + +-- CreateIndex +CREATE INDEX "TeamMember_teamId_idx" ON "TeamMember"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMember_teamId_userId_key" ON "TeamMember"("teamId", "userId"); + +-- AddForeignKey +ALTER TABLE "Team" ADD CONSTRAINT "Team_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c6f412f..7e06905 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -155,6 +155,7 @@ model Organization { users User[] memberships Membership[] orgRoles OrgRole[] + teams Team[] companies Company[] people Person[] pipelines Pipeline[] @@ -241,6 +242,7 @@ model User { auditLogs AuditLog[] @relation("AuditLogActor") savedViews SavedView[] @relation("SavedViewOwner") assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees") + teamMemberships TeamMember[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -281,6 +283,8 @@ model Membership { orgRole OrgRole? @relation(fields: [orgRoleId], references: [id], onDelete: SetNull) isActive Boolean @default(true) commissionPercent Decimal @default(0) @db.Decimal(8, 2) + /// own | team | org — سطح دید داده‌ها برای نقش‌های غیرمالک/مدیر + dataVisibility String @default("own") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -290,6 +294,36 @@ model Membership { @@index([orgRoleId]) } +/// گروه کاری داخل سازمان برای دیدپذیری مشترک +model Team { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String + description String @default("") + members TeamMember[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, name]) + @@index([organizationId]) +} + +model TeamMember { + id String @id @default(cuid()) + teamId String + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// member | manager + role String @default("member") + createdAt DateTime @default(now()) + + @@unique([teamId, userId]) + @@index([userId]) + @@index([teamId]) +} + /// حساب سوپرادمین پلتفرم — جدا از User سازمانی model PlatformAdmin { id String @id @default(cuid()) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a0c5870..ce268a4 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts index 82beb0b..29ae1b8 100644 --- a/apps/api/src/auth/jwt.strategy.ts +++ b/apps/api/src/auth/jwt.strategy.ts @@ -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 { + 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)])]; + } } diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts index 86c5257..3681312 100644 --- a/apps/api/src/common/record-scope.ts +++ b/apps/api/src/common/record-scope.ts @@ -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, +): 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, +): 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, -): { ownerId: string } | Record { - if (elevated(actor.role)) return {}; - return { ownerId: actor.userId }; + actor: Pick, +): { ownerId: string } | { ownerId: { in: string[] } } | Record { + const ids = scopedOwnerIds(actor); + if (!ids) return {}; + if (ids.length === 1) return { ownerId: ids[0]! }; + return { ownerId: { in: ids } }; } /** فعالیت‌ها بر اساس مسئول */ export function assigneeWhere( - actor: Pick, -): { assigneeId: string } | Record { - if (elevated(actor.role)) return {}; - return { assigneeId: actor.userId }; + actor: Pick, +): + | { assigneeId: string } + | { assigneeId: { in: string[] } } + | Record { + 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: Pick, ): 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: Pick, ): 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'; +} diff --git a/apps/api/src/sales/sales.service.ts b/apps/api/src/sales/sales.service.ts index cf04435..df91113 100644 --- a/apps/api/src/sales/sales.service.ts +++ b/apps/api/src/sales/sales.service.ts @@ -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 }], }; } diff --git a/apps/api/src/teams/dto.ts b/apps/api/src/teams/dto.ts new file mode 100644 index 0000000..cc2a752 --- /dev/null +++ b/apps/api/src/teams/dto.ts @@ -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[]; +} diff --git a/apps/api/src/teams/teams.controller.ts b/apps/api/src/teams/teams.controller.ts new file mode 100644 index 0000000..da19cd4 --- /dev/null +++ b/apps/api/src/teams/teams.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/teams/teams.module.ts b/apps/api/src/teams/teams.module.ts new file mode 100644 index 0000000..ac5d39a --- /dev/null +++ b/apps/api/src/teams/teams.module.ts @@ -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 {} diff --git a/apps/api/src/teams/teams.service.ts b/apps/api/src/teams/teams.service.ts new file mode 100644 index 0000000..7a4def5 --- /dev/null +++ b/apps/api/src/teams/teams.service.ts @@ -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 { + 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 { + 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 { + 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 { + await this.ensureTeam(organizationId, id); + await this.prisma.team.delete({ where: { id } }); + } + + async setMembers( + organizationId: string, + id: string, + dto: SetTeamMembersRequest, + ): Promise { + 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(); + 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 { + 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(), + }; + } +} diff --git a/apps/api/src/users/dto.ts b/apps/api/src/users/dto.ts index 1d8099b..f2e552a 100644 --- a/apps/api/src/users/dto.ts +++ b/apps/api/src/users/dto.ts @@ -67,3 +67,8 @@ export class UpdateUserCommissionDto { @Max(100) commissionPercent!: number; } + +export class UpdateUserVisibilityDto { + @IsIn(['own', 'team', 'org']) + dataVisibility!: 'own' | 'team' | 'org'; +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index c8370f2..00beada 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -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( diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 1a6b2e9..6650e5a 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -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 { + 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(), }; } diff --git a/apps/web/src/components/TeamsSettingsPanel.vue b/apps/web/src/components/TeamsSettingsPanel.vue new file mode 100644 index 0000000..5058f8e --- /dev/null +++ b/apps/web/src/components/TeamsSettingsPanel.vue @@ -0,0 +1,362 @@ + + + + + diff --git a/apps/web/src/lib/nav-hubs.ts b/apps/web/src/lib/nav-hubs.ts index 5180efe..0c18ea6 100644 --- a/apps/web/src/lib/nav-hubs.ts +++ b/apps/web/src/lib/nav-hubs.ts @@ -350,6 +350,15 @@ export const NAV_HUBS: NavHubDef[] = [ tint: 'violet', module: 'team_settings', }, + { + to: '/settings/team#settings-teams', + title: 'تیم‌ها و دیدپذیری', + desc: 'گروه‌های کاری و سطح مشاهده پرونده‌ها', + icon: 'users', + tint: 'sky', + module: 'teams', + admin: true, + }, { to: '/settings/audit-logs', title: 'لاگ فعالیت کاربران', diff --git a/apps/web/src/views/TeamSettingsView.vue b/apps/web/src/views/TeamSettingsView.vue index e56cb47..1f75be2 100644 --- a/apps/web/src/views/TeamSettingsView.vue +++ b/apps/web/src/views/TeamSettingsView.vue @@ -23,8 +23,9 @@ import { formatJalaliNumeric, toFaDigits, } from '@/lib/jalali'; +import TeamsSettingsPanel from '@/components/TeamsSettingsPanel.vue'; -type TabKey = 'users' | 'roles' | 'access'; +type TabKey = 'users' | 'roles' | 'access' | 'teams'; type RoleColor = 'teal' | 'violet' | 'rose' | 'sky' | 'amber' | 'slate' | 'pink'; const PAGE_SIZE = 10; @@ -154,12 +155,20 @@ const inviteableRoles = computed(() => function tabFromHash(hash: string): TabKey { if (hash.includes('settings-org') || hash.includes('access')) return 'access'; if (hash.includes('roles')) return 'roles'; + if (hash.includes('teams') || hash.includes('settings-teams')) return 'teams'; return 'users'; } function setTab(next: TabKey) { tab.value = next; - const hash = next === 'access' ? 'settings-org' : next === 'roles' ? 'roles' : 'settings-team'; + const hash = + next === 'access' + ? 'settings-org' + : next === 'roles' + ? 'roles' + : next === 'teams' + ? 'settings-teams' + : 'settings-team'; if (route.hash.replace('#', '') !== hash) { void router.replace({ hash: `#${hash}` }); } @@ -501,12 +510,20 @@ onUnmounted(() => {

{{ error }}

{{ ok }}

+ +

سازمان

@@ -597,7 +614,7 @@ onUnmounted(() => {
-
+