diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index a2ebd20..916beae 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -30,6 +30,7 @@ import { dealAccessWhere, ownerWhere, } from '../common/record-scope'; +import { assertAssigneeSet } from '../common/assign-access'; import { normalizeTags } from '../common/tags'; import { GamificationService } from '../gamification/gamification.service'; import { tehranDayKey } from '../routines/routine-dates'; @@ -566,6 +567,12 @@ export class ActivitiesService { ? [dto.assigneeId] : [userId], ); + await assertAssigneeSet( + this.prisma, + { userId, role: linkActor.role, organizationId }, + 'activities', + assigneeIds, + ); const assigneeId = assigneeIds[0] ?? userId; const initialStageId = emptyToNull(dto.stageId ?? null); const initialStage = initialStageId @@ -587,6 +594,12 @@ export class ActivitiesService { resolved.fields, dto.fields, ); + await assertAssigneeSet( + this.prisma, + { userId, role: linkActor.role, organizationId }, + 'activities', + participantIds, + ); const row = await this.prisma.activity.create({ data: { @@ -723,6 +736,13 @@ export class ActivitiesService { data.typeDefId = resolved.typeDefId; data.fields = fields as Prisma.InputJsonValue; data.participantIds = participantIds; + await assertAssigneeSet( + this.prisma, + { userId: actor.userId, role: actor.role, organizationId }, + 'activities', + participantIds, + existing?.participantIds ?? [], + ); const before = new Set(existing?.participantIds ?? []); addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId); } @@ -748,22 +768,47 @@ export class ActivitiesService { if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); let assigneeConnect: Array<{ id: string }> | undefined; - if (dto.assigneeIds !== undefined) { - const assigneeIds = await this.normalizeAssigneeIds( - organizationId, - dto.assigneeIds, - ); - if (!assigneeIds.length) { - throw new BadRequestException('حداقل یک مسئول لازم است'); + if (dto.assigneeIds !== undefined || 'assigneeId' in dto) { + const current = await this.prisma.activity.findFirst({ + where: { id, organizationId }, + select: { assigneeId: true, assignees: { select: { id: true } } }, + }); + const previous = current?.assignees.length + ? current.assignees.map((item) => item.id) + : current?.assigneeId + ? [current.assigneeId] + : []; + if (dto.assigneeIds !== undefined) { + const assigneeIds = await this.normalizeAssigneeIds( + organizationId, + dto.assigneeIds, + ); + if (!assigneeIds.length) { + throw new BadRequestException('حداقل یک مسئول لازم است'); + } + await assertAssigneeSet( + this.prisma, + { userId: actor.userId, role: actor.role, organizationId }, + 'activities', + assigneeIds, + previous, + ); + data.assigneeId = assigneeIds[0]!; + assigneeConnect = assigneeIds.map((uid) => ({ id: uid })); + } else { + const assigneeId = emptyToNull(dto.assigneeId ?? null); + if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId); + await assertAssigneeSet( + this.prisma, + { userId: actor.userId, role: actor.role, organizationId }, + 'activities', + [assigneeId], + previous, + ); + data.assigneeId = assigneeId; + if (assigneeId) assigneeConnect = [{ id: assigneeId }]; + else assigneeConnect = []; } - data.assigneeId = assigneeIds[0]!; - assigneeConnect = assigneeIds.map((uid) => ({ id: uid })); - } else if ('assigneeId' in dto) { - const assigneeId = emptyToNull(dto.assigneeId ?? null); - if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId); - data.assigneeId = assigneeId; - if (assigneeId) assigneeConnect = [{ id: assigneeId }]; - else assigneeConnect = []; } if (dto.status !== undefined) { data.status = dto.status; diff --git a/apps/api/src/common/assign-access.ts b/apps/api/src/common/assign-access.ts new file mode 100644 index 0000000..c96d29c --- /dev/null +++ b/apps/api/src/common/assign-access.ts @@ -0,0 +1,62 @@ +import { ForbiddenException } from '@nestjs/common'; +import { + ASSIGN_SECTION_PERMISSION, + type AssignSection, +} from '@mento/shared'; +import type { PrismaService } from '../prisma/prisma.service'; +import { elevated } from './record-scope'; + +const ASSIGN_NOUN: Record = { + customers: 'مشتری', + leads: 'لید', + sales: 'معامله', + activities: 'فعالیت', + routines: 'کار روتین', + projects: 'کارت پروژه', + tickets: 'تیکت', +}; + +type AssignActor = { userId: string; role: string; organizationId: string }; + +export function assignOthersDeniedMessage(section: AssignSection): string { + return `تخصیص ${ASSIGN_NOUN[section]} به دیگران برای نقش شما مجاز نیست. فقط می‌توانید برای خودتان ثبت کنید.`; +} + +export async function actorCanAssignToOthers( + prisma: PrismaService, + actor: AssignActor, + section: AssignSection, +): Promise { + if (elevated(actor.role)) return true; + const membership = await prisma.membership.findFirst({ + where: { + userId: actor.userId, + organizationId: actor.organizationId, + isActive: true, + }, + select: { orgRole: { select: { permissions: true } } }, + }); + const permissions = membership?.orgRole?.permissions; + const key = ASSIGN_SECTION_PERMISSION[section]; + return Array.isArray(permissions) && permissions.includes(key); +} + +/** + * بدون مجوز همان بخش، مجموعهٔ مسئولانِ غیر از خود کاربر نباید عوض شود. + * ثبت تازه یعنی قبلی خالی است، پس فقط خود کاربر مجاز است. + */ +export async function assertAssigneeSet( + prisma: PrismaService, + actor: AssignActor, + section: AssignSection, + nextIds: Array, + previousIds: Array = [], +): Promise { + if (await actorCanAssignToOthers(prisma, actor, section)) return; + const prev = new Set( + previousIds.filter((id): id is string => Boolean(id) && id !== actor.userId), + ); + const next = nextIds.filter((id): id is string => Boolean(id) && id !== actor.userId); + const changed = next.length !== prev.size || next.some((id) => !prev.has(id)); + if (changed) throw new ForbiddenException(assignOthersDeniedMessage(section)); +} diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts index 00f8ac9..7bd435e 100644 --- a/apps/api/src/common/record-scope.ts +++ b/apps/api/src/common/record-scope.ts @@ -225,8 +225,10 @@ export function noteAccessWhere( export function resolveOwnerId( actor: Pick, requested?: string | null, + allowOthers?: boolean, ): string { - if (elevated(actor.role) && requested?.trim()) { + const allowed = allowOthers ?? elevated(actor.role); + if (allowed && requested?.trim()) { return requested.trim(); } return actor.userId; diff --git a/apps/api/src/contacts/companies.service.ts b/apps/api/src/contacts/companies.service.ts index 04db5b1..c44e60d 100644 --- a/apps/api/src/contacts/companies.service.ts +++ b/apps/api/src/contacts/companies.service.ts @@ -20,10 +20,10 @@ import { import { customersCompanyWhere } from '../common/data-access'; import { type Actor, - canReassignOwner, elevated, resolveOwnerId, } from '../common/record-scope'; +import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; import { normalizeTags } from '../common/tags'; import { normalizePersonImportSource } from './person-source'; import { buildSearchWhere, ci, splitCsv } from '../common/search'; @@ -201,7 +201,11 @@ export class CompaniesService { notes: emptyToNull(dto.notes), registeredAt: parseDateOrNull(dto.registeredAt), tags: normalizeTags(dto.tags), - ownerId: resolveOwnerId(actor, dto.ownerId), + ownerId: resolveOwnerId( + actor, + dto.ownerId, + await actorCanAssignToOthers(this.prisma, actor, 'customers'), + ), customFields: toJsonCustomFields(dto.customFields), }; await this.assertNoDuplicate(actor.organizationId, data); @@ -266,8 +270,14 @@ export class CompaniesService { if ('customFields' in dto) { data.customFields = toJsonCustomFields(dto.customFields); } - if (dto.ownerId !== undefined && canReassignOwner(actor.role)) { - data.ownerId = resolveOwnerId(actor, dto.ownerId); + if (dto.ownerId !== undefined) { + const current = await this.prisma.company.findFirst({ + where: { id, organizationId: actor.organizationId }, + select: { ownerId: true }, + }); + const nextOwner = emptyToNull(dto.ownerId ?? null); + await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]); + data.ownerId = nextOwner ?? actor.userId; } await this.assertNoDuplicate(actor.organizationId, data, id); diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 9509398..3e5e748 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -38,9 +38,9 @@ import { import { customersPersonWhere } from '../common/data-access'; import { type Actor, - canReassignOwner, resolveOwnerId, } from '../common/record-scope'; +import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; import { LoyaltyService } from '../loyalty/loyalty.service'; import { AutomationsService } from '../automations/automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service'; @@ -453,7 +453,11 @@ export class PeopleService implements OnModuleInit { mood: dto.mood ?? 'newcomer', tags: normalizeTags(dto.tags), companyId, - ownerId: resolveOwnerId(actor, dto.ownerId), + ownerId: resolveOwnerId( + actor, + dto.ownerId, + await actorCanAssignToOthers(this.prisma, actor, 'customers'), + ), customFields: toJsonCustomFields(dto.customFields), ...(registeredAt && !Number.isNaN(registeredAt.getTime()) ? { createdAt: registeredAt } @@ -574,8 +578,14 @@ export class PeopleService implements OnModuleInit { } data.companyId = nextCompanyId; } - if (dto.ownerId !== undefined && canReassignOwner(actor.role)) { - data.ownerId = resolveOwnerId(actor, dto.ownerId); + if (dto.ownerId !== undefined) { + const current = await this.prisma.person.findFirst({ + where: { id, organizationId: actor.organizationId }, + select: { ownerId: true }, + }); + const nextOwner = emptyToNull(dto.ownerId ?? null); + await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]); + data.ownerId = nextOwner ?? actor.userId; } await this.assertNoDuplicate(actor.organizationId, data, id); diff --git a/apps/api/src/leads/leads.controller.ts b/apps/api/src/leads/leads.controller.ts index 2c2f96d..c346ed1 100644 --- a/apps/api/src/leads/leads.controller.ts +++ b/apps/api/src/leads/leads.controller.ts @@ -302,8 +302,6 @@ export class LeadsController { } @Post('assign') - @UseGuards(RolesGuard) - @Roles('owner', 'admin') assign(@CurrentUser() user: Actor, @Body() dto: AssignLeadsDto) { return this.leads .assign(user, dto.leadIds, dto.assigneeId) diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index fc39e75..6a1d0f4 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -31,6 +31,7 @@ import { elevated, forScope, } from '../common/record-scope'; +import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; import { asAddresses, collectAddresses, @@ -379,10 +380,11 @@ export class LeadsService { const stageId = dto.stageId || stages[0]?.id; if (!stageId) throw new BadRequestException('مرحله‌ای تعریف نشده'); + const allowOthers = await actorCanAssignToOthers(this.prisma, actor, 'leads'); let assigneeId: string | null = null; - if (elevated(actor.role) && dto.assigneeId) { + if (allowOthers && dto.assigneeId) { assigneeId = dto.assigneeId; - } else if (!elevated(actor.role)) { + } else if (!allowOthers) { assigneeId = actor.userId; } @@ -570,8 +572,9 @@ export class LeadsService { if (typeof dto.stageId === 'string' && dto.stageId !== existing.stageId) { data.stage = { connect: { id: dto.stageId } }; } - if (elevated(actor.role) && dto.assigneeId !== undefined) { + if (dto.assigneeId !== undefined) { const aid = dto.assigneeId ? String(dto.assigneeId) : null; + await assertAssigneeSet(this.prisma, actor, 'leads', [aid], [existing.assigneeId]); data.assignee = aid ? { connect: { id: aid } } : { disconnect: true }; @@ -641,9 +644,7 @@ export class LeadsService { leadIds: string[], assigneeId: string, ): Promise { - if (!elevated(actor.role)) { - throw new BadRequestException('فقط مدیر می‌تواند تخصیص دهد'); - } + await assertAssigneeSet(this.prisma, actor, 'leads', [assigneeId]); const user = await this.prisma.user.findFirst({ where: { id: assigneeId, diff --git a/apps/api/src/org-roles/role-defaults.ts b/apps/api/src/org-roles/role-defaults.ts index fa1fefe..af4ef78 100644 --- a/apps/api/src/org-roles/role-defaults.ts +++ b/apps/api/src/org-roles/role-defaults.ts @@ -41,7 +41,9 @@ export const SYSTEM_ROLE_SEEDS: Array<{ name: 'کارشناس فروش', description: 'امکان دسترسی به بخش‌های فروش و مشتریان برای انجام فعالیت‌های روزمره.', color: 'teal', - permissions: ALL_ROLE_PERMISSIONS.filter((key) => key !== 'audit'), + permissions: ALL_ROLE_PERMISSIONS.filter( + (key) => key !== 'audit' && !key.startsWith('assign_'), + ), }, { systemKey: 'support', diff --git a/apps/api/src/pipelines/deals.service.ts b/apps/api/src/pipelines/deals.service.ts index 34f62f0..7528e57 100644 --- a/apps/api/src/pipelines/deals.service.ts +++ b/apps/api/src/pipelines/deals.service.ts @@ -22,11 +22,11 @@ import { WebhooksService } from '../public-api/webhooks.service'; import { GamificationService } from '../gamification/gamification.service'; import { type Actor, - canReassignOwner, dealAccessWhere, ownerWhere, resolveOwnerId, } from '../common/record-scope'; +import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; const dealInclude = { person: { select: { id: true, firstName: true, lastName: true } }, @@ -106,6 +106,10 @@ export class DealsService { organizationId, dto.assigneeIds, ); + const assignActor = { userId: actor.userId, role: actor.role, organizationId }; + const allowOthers = await actorCanAssignToOthers(this.prisma, assignActor, 'sales'); + const ownerId = resolveOwnerId(actor, dto.ownerId, allowOthers); + await assertAssigneeSet(this.prisma, assignActor, 'sales', [ownerId, ...assigneeIds]); const maxPos = await this.prisma.deal.aggregate({ where: { organizationId, stageId: stage.id }, @@ -156,7 +160,7 @@ export class DealsService { createdById: actor.userId, personId: emptyToNull(dto.personId ?? null), companyId: emptyToNull(dto.companyId ?? null), - ownerId: resolveOwnerId(actor, dto.ownerId), + ownerId, position: (maxPos._max.position ?? -1) + 1, ...(assigneeIds.length ? { assignees: { connect: assigneeIds.map((id) => ({ id })) } } @@ -259,17 +263,38 @@ export class DealsService { } if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null); if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); - if (dto.ownerId !== undefined && canReassignOwner(actor.role)) { - data.ownerId = resolveOwnerId(actor, dto.ownerId); - } - let assigneeConnect: { id: string }[] | undefined; - if (dto.assigneeIds !== undefined) { - const assigneeIds = await this.normalizeAssigneeIds( - organizationId, - dto.assigneeIds, - ); - assigneeConnect = assigneeIds.map((uid) => ({ id: uid })); + if (dto.ownerId !== undefined || dto.assigneeIds !== undefined) { + const current = await this.prisma.deal.findFirst({ + where: { id, organizationId }, + select: { ownerId: true, assignees: { select: { id: true } } }, + }); + const assignActor = { userId: actor.userId, role: actor.role, organizationId }; + if (dto.ownerId !== undefined) { + const nextOwner = emptyToNull(dto.ownerId ?? null); + await assertAssigneeSet( + this.prisma, + assignActor, + 'sales', + [nextOwner], + [current?.ownerId], + ); + data.ownerId = nextOwner ?? actor.userId; + } + if (dto.assigneeIds !== undefined) { + const assigneeIds = await this.normalizeAssigneeIds( + organizationId, + dto.assigneeIds, + ); + await assertAssigneeSet( + this.prisma, + assignActor, + 'sales', + assigneeIds, + current?.assignees.map((item) => item.id) ?? [], + ); + assigneeConnect = assigneeIds.map((uid) => ({ id: uid })); + } } if (pricedFromLines && dto.lineItems) { diff --git a/apps/api/src/project-boards/project-boards.service.ts b/apps/api/src/project-boards/project-boards.service.ts index 503ffc2..f277b19 100644 --- a/apps/api/src/project-boards/project-boards.service.ts +++ b/apps/api/src/project-boards/project-boards.service.ts @@ -15,12 +15,12 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { type Actor, - canReassignOwner, dealAccessWhere, ownerWhere, projectCardAccessWhere, resolveOwnerId, } from '../common/record-scope'; +import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; import { normalizeTags } from '../common/tags'; const DEFAULT_STAGES = ['صف', 'در حال انجام', 'بازبینی', 'انجام‌شده'] as const; @@ -206,6 +206,13 @@ export class ProjectBoardsService { }); const assigneeIds = [...new Set((dto.assigneeIds ?? []).map((id) => id.trim()).filter(Boolean))]; + const assignActor = { userId: actor.userId, role: actor.role, organizationId }; + const ownerId = resolveOwnerId( + actor, + dto.ownerId, + await actorCanAssignToOthers(this.prisma, assignActor, 'projects'), + ); + await assertAssigneeSet(this.prisma, assignActor, 'projects', [ownerId, ...assigneeIds]); const created = await this.prisma.projectCard.create({ data: { organizationId, @@ -217,7 +224,7 @@ export class ProjectBoardsService { personId: emptyToNull(dto.personId), companyId: emptyToNull(dto.companyId), dealId: emptyToNull(dto.dealId), - ownerId: resolveOwnerId(actor, dto.ownerId), + ownerId, position: (maxPos._max.position ?? -1) + 1, ...(assigneeIds.length ? { assignees: { connect: assigneeIds.map((id) => ({ id })) } } @@ -257,24 +264,40 @@ export class ProjectBoardsService { if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); if ('ownerId' in dto) { - if (!canReassignOwner(actor.role) && dto.ownerId !== existing.ownerId) { - throw new BadRequestException('تغییر مسئول مجاز نیست'); - } - data.ownerId = emptyToNull(dto.ownerId ?? null); + const nextOwner = emptyToNull(dto.ownerId ?? null); + await assertAssigneeSet( + this.prisma, + { userId: actor.userId, role: actor.role, organizationId }, + 'projects', + [nextOwner], + [existing.ownerId], + ); + data.ownerId = nextOwner; + } + + const nextAssigneeIds = dto.assigneeIds + ? [...new Set(dto.assigneeIds.map((x) => x.trim()).filter(Boolean))] + : undefined; + if (nextAssigneeIds) { + const currentAssignees = await this.prisma.projectCard.findFirst({ + where: { id }, + select: { assignees: { select: { id: true } } }, + }); + await assertAssigneeSet( + this.prisma, + { userId: actor.userId, role: actor.role, organizationId }, + 'projects', + nextAssigneeIds, + currentAssignees?.assignees.map((item) => item.id) ?? [], + ); } const row = await this.prisma.projectCard.update({ where: { id }, data: { ...data, - ...(dto.assigneeIds - ? { - assignees: { - set: [...new Set(dto.assigneeIds.map((x) => x.trim()).filter(Boolean))].map( - (uid) => ({ id: uid }), - ), - }, - } + ...(nextAssigneeIds + ? { assignees: { set: nextAssigneeIds.map((uid) => ({ id: uid })) } } : {}), }, include: cardInclude, diff --git a/apps/api/src/routines/routines.service.ts b/apps/api/src/routines/routines.service.ts index 98b6cea..c8410b6 100644 --- a/apps/api/src/routines/routines.service.ts +++ b/apps/api/src/routines/routines.service.ts @@ -13,6 +13,7 @@ import type { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import type { Actor } from '../common/record-scope'; import { elevated } from '../common/record-scope'; +import { actorCanAssignToOthers, assignOthersDeniedMessage } from '../common/assign-access'; import { ChangeRoutineAssigneeDto, ListRoutinesQueryDto, @@ -360,6 +361,11 @@ export class RoutinesService { assigneeId?: string | null, teamId?: string | null, ): Promise<{ assigneeId: string | null; teamId: string | null }> { + if ((assigneeId && assigneeId !== actor.userId) || teamId) { + if (!(await actorCanAssignToOthers(this.prisma, actor, 'routines'))) { + throw new ForbiddenException(assignOthersDeniedMessage('routines')); + } + } if (assigneeId) { if (!canManage(scope, assigneeId)) { throw new ForbiddenException('اجازه تعریف روتین برای این کاربر را ندارید'); diff --git a/apps/api/src/tickets/tickets.service.ts b/apps/api/src/tickets/tickets.service.ts index 09aa1ca..e6cac7f 100644 --- a/apps/api/src/tickets/tickets.service.ts +++ b/apps/api/src/tickets/tickets.service.ts @@ -20,6 +20,7 @@ import { elevated, ticketAccessWhere, } from '../common/record-scope'; +import { assertAssigneeSet } from '../common/assign-access'; import { NotificationsService } from '../notifications/notifications.service'; import { AutomationsService } from '../automations/automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service'; @@ -136,9 +137,7 @@ export class TicketsService { if (assigneeId) { await this.assertUser(actor.organizationId, assigneeId); } - if (!elevated(actor.role) && assigneeId && assigneeId !== actor.userId) { - assigneeId = actor.userId; - } + await assertAssigneeSet(this.prisma, actor, 'tickets', [assigneeId]); const number = await this.nextNumber(actor.organizationId); const status = (dto.status as never) || 'open'; @@ -270,9 +269,7 @@ export class TicketsService { if ('assigneeId' in dto) { const next = emptyToNull(dto.assigneeId ?? null); if (next) await this.assertUser(actor.organizationId, next); - if (!elevated(actor.role) && next && next !== actor.userId) { - throw new BadRequestException('فقط می‌توانید تیکت را به خودتان ارجاع دهید'); - } + await assertAssigneeSet(this.prisma, actor, 'tickets', [next], [existing.assigneeId]); data.assigneeId = next; if (next && next !== existing.assigneeId && next !== actor.userId) { await this.notifications.create({ diff --git a/apps/web/src/components/activities/ActivityFieldsEditor.vue b/apps/web/src/components/activities/ActivityFieldsEditor.vue index 7b5dfa2..28cd6ec 100644 --- a/apps/web/src/components/activities/ActivityFieldsEditor.vue +++ b/apps/web/src/components/activities/ActivityFieldsEditor.vue @@ -1,5 +1,5 @@