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
@@ -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,