feat(roles): let each section decide who may assign work to others

Section access now only creates a record for yourself. Assigning it to a colleague requires a separate permission on that section.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-03 13:15:21 +03:30
co-authored by Cursor
parent 79733e6c96
commit 534dae45b2
25 changed files with 355 additions and 84 deletions
+60 -15
View File
@@ -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;
+62
View File
@@ -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<AssignSection, string> = {
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<boolean> {
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<string | null | undefined>,
previousIds: Array<string | null | undefined> = [],
): Promise<void> {
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));
}
+3 -1
View File
@@ -225,8 +225,10 @@ export function noteAccessWhere(
export function resolveOwnerId(
actor: Pick<Actor, 'userId' | 'role'>,
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;
+14 -4
View File
@@ -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);
+14 -4
View File
@@ -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);
-2
View File
@@ -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)
+7 -6
View File
@@ -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<number> {
if (!elevated(actor.role)) {
throw new BadRequestException('فقط مدیر می‌تواند تخصیص دهد');
}
await assertAssigneeSet(this.prisma, actor, 'leads', [assigneeId]);
const user = await this.prisma.user.findFirst({
where: {
id: assigneeId,
+3 -1
View File
@@ -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',
+37 -12
View File
@@ -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) {
@@ -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,
@@ -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('اجازه تعریف روتین برای این کاربر را ندارید');
+3 -6
View File
@@ -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({