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
+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) {