Files
CRM_Montzar/apps/api/src/project-boards/project-boards.service.ts
T
MahdiandCursor 534dae45b2 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>
2026-11-03 13:15:21 +03:30

556 lines
17 KiB
TypeScript

import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateProjectCardRequest,
MoveProjectCardRequest,
ProjectBoardBoardDto,
ProjectBoardDto,
ProjectCardDto,
UpdateProjectCardRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
type Actor,
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;
const cardInclude = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
owner: { select: { id: true, fullName: true } },
assignees: { select: { id: true, fullName: true } },
} satisfies Prisma.ProjectCardInclude;
type CardActor = Pick<
Actor,
| 'userId'
| 'role'
| 'dataVisibility'
| 'visibleUserIds'
| 'scopes'
| 'projectsAccess'
| 'teamPeerIds'
>;
@Injectable()
export class ProjectBoardsService {
constructor(private readonly prisma: PrismaService) {}
async ensureDefaultBoard(organizationId: string): Promise<ProjectBoardDto> {
const existing = await this.prisma.projectBoard.findFirst({
where: { organizationId, isDefault: true },
include: { stages: { orderBy: { position: 'asc' } } },
});
if (existing) return this.toBoardDto(existing);
const created = await this.prisma.projectBoard.create({
data: {
organizationId,
name: 'پروژه داخلی',
isDefault: true,
stages: {
create: DEFAULT_STAGES.map((name, position) => ({ name, position })),
},
},
include: { stages: { orderBy: { position: 'asc' } } },
});
return this.toBoardDto(created);
}
async list(organizationId: string): Promise<ProjectBoardDto[]> {
await this.ensureDefaultBoard(organizationId);
const rows = await this.prisma.projectBoard.findMany({
where: { organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
});
return rows.map((row) => this.toBoardDto(row));
}
async create(organizationId: string, name: string): Promise<ProjectBoardDto> {
await this.ensureDefaultBoard(organizationId);
const created = await this.prisma.projectBoard.create({
data: {
organizationId,
name: name.trim(),
isDefault: false,
stages: {
create: DEFAULT_STAGES.map((n, position) => ({ name: n, position })),
},
},
include: { stages: { orderBy: { position: 'asc' } } },
});
return this.toBoardDto(created);
}
async getBoard(
organizationId: string,
boardId?: string,
actor?: CardActor,
): Promise<ProjectBoardBoardDto> {
const board = boardId
? await this.prisma.projectBoard.findFirst({
where: { id: boardId, organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
})
: null;
const resolved =
board ??
(await this.prisma.projectBoard.findFirst({
where: { organizationId, isDefault: true },
include: { stages: { orderBy: { position: 'asc' } } },
}));
if (!resolved) {
const created = await this.ensureDefaultBoard(organizationId);
return this.getBoard(organizationId, created.id, actor);
}
const cards = await this.prisma.projectCard.findMany({
where: {
organizationId,
boardId: resolved.id,
...(actor ? projectCardAccessWhere(actor) : {}),
},
include: cardInclude,
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
});
const byStage = new Map<string, ProjectCardDto[]>();
for (const stage of resolved.stages) byStage.set(stage.id, []);
for (const card of cards) {
const list = byStage.get(card.stageId);
if (list) list.push(this.toCardDto(card));
}
return {
board: this.toBoardDto(resolved),
stages: resolved.stages.map((stage) => ({
id: stage.id,
name: stage.name,
position: stage.position,
cards: byStage.get(stage.id) ?? [],
})),
};
}
async getCard(
organizationId: string,
id: string,
actor: CardActor,
): Promise<ProjectCardDto> {
const row = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...projectCardAccessWhere(actor) },
include: cardInclude,
});
if (!row) throw new NotFoundException('کارت پیدا نشد');
return this.toCardDto(row);
}
async createCard(
organizationId: string,
actor: CardActor,
dto: CreateProjectCardRequest,
): Promise<ProjectCardDto> {
const board =
dto.boardId != null
? await this.prisma.projectBoard.findFirst({
where: { id: dto.boardId, organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
})
: null;
const resolved =
board ??
(await this.prisma.projectBoard.findFirst({
where: { organizationId, isDefault: true },
include: { stages: { orderBy: { position: 'asc' } } },
}));
if (!resolved) {
await this.ensureDefaultBoard(organizationId);
return this.createCard(organizationId, actor, dto);
}
const stage =
(dto.stageId
? resolved.stages.find((s) => s.id === dto.stageId)
: resolved.stages[0]) ?? resolved.stages[0];
if (!stage) throw new BadRequestException('برد مرحله‌ای ندارد');
await this.assertLinks(
organizationId,
actor,
dto.personId,
dto.companyId,
dto.dealId,
);
const maxPos = await this.prisma.projectCard.aggregate({
where: { organizationId, stageId: stage.id },
_max: { position: true },
});
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,
boardId: resolved.id,
stageId: stage.id,
title: dto.title.trim(),
notes: emptyToNull(dto.notes),
tags: normalizeTags(dto.tags),
personId: emptyToNull(dto.personId),
companyId: emptyToNull(dto.companyId),
dealId: emptyToNull(dto.dealId),
ownerId,
position: (maxPos._max.position ?? -1) + 1,
...(assigneeIds.length
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
: {}),
},
include: cardInclude,
});
return this.toCardDto(created);
}
async updateCard(
organizationId: string,
id: string,
actor: CardActor,
dto: UpdateProjectCardRequest,
): Promise<ProjectCardDto> {
const existing = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...projectCardAccessWhere(actor) },
});
if (!existing) throw new NotFoundException('کارت پیدا نشد');
if ('personId' in dto || 'companyId' in dto || 'dealId' in dto) {
await this.assertLinks(
organizationId,
actor,
dto.personId ?? existing.personId,
dto.companyId ?? existing.companyId,
dto.dealId ?? existing.dealId,
);
}
const data: Prisma.ProjectCardUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('ownerId' in dto) {
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,
...(nextAssigneeIds
? { assignees: { set: nextAssigneeIds.map((uid) => ({ id: uid })) } }
: {}),
},
include: cardInclude,
});
return this.toCardDto(row);
}
async moveCard(
organizationId: string,
id: string,
actor: CardActor,
dto: MoveProjectCardRequest,
): Promise<ProjectCardDto> {
const card = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...projectCardAccessWhere(actor) },
select: { id: true, boardId: true },
});
if (!card) throw new NotFoundException('کارت پیدا نشد');
const stage = await this.prisma.projectBoardStage.findFirst({
where: { id: dto.stageId, boardId: card.boardId },
});
if (!stage) throw new BadRequestException('مرحله در این برد معتبر نیست');
const position =
dto.position ??
((
await this.prisma.projectCard.aggregate({
where: { organizationId, stageId: stage.id, NOT: { id } },
_max: { position: true },
})
)._max.position ?? -1) + 1;
const row = await this.prisma.projectCard.update({
where: { id },
data: { stageId: stage.id, position },
include: cardInclude,
});
return this.toCardDto(row);
}
async removeCard(
organizationId: string,
id: string,
actor: CardActor,
): Promise<void> {
const found = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...projectCardAccessWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('کارت پیدا نشد');
await this.prisma.projectCard.delete({ where: { id } });
}
async createStage(
organizationId: string,
boardId: string,
name: string,
): Promise<ProjectBoardDto> {
const board = await this.getOwnedBoard(organizationId, boardId);
const trimmed = name.trim();
if (!trimmed) throw new BadRequestException('نام ستون لازم است');
await this.prisma.projectBoardStage.create({
data: {
boardId: board.id,
name: trimmed,
position: board.stages.length,
},
});
return this.getOwnedBoardDto(organizationId, board.id);
}
async updateStage(
organizationId: string,
boardId: string,
stageId: string,
name?: string,
): Promise<ProjectBoardDto> {
const board = await this.getOwnedBoard(organizationId, boardId);
const stage = board.stages.find((s) => s.id === stageId);
if (!stage) throw new NotFoundException('ستون پیدا نشد');
const trimmed = name?.trim() ?? '';
if (!trimmed) throw new BadRequestException('نام ستون لازم است');
await this.prisma.projectBoardStage.update({
where: { id: stage.id },
data: { name: trimmed },
});
return this.getOwnedBoardDto(organizationId, board.id);
}
async deleteStage(
organizationId: string,
boardId: string,
stageId: string,
): Promise<ProjectBoardDto> {
const board = await this.getOwnedBoard(organizationId, boardId);
const stage = board.stages.find((s) => s.id === stageId);
if (!stage) throw new NotFoundException('ستون پیدا نشد');
if (board.stages.length <= 1) {
throw new BadRequestException('حداقل یک ستون باید باقی بماند');
}
const cardCount = await this.prisma.projectCard.count({
where: { organizationId, stageId: stage.id },
});
if (cardCount > 0) {
throw new BadRequestException(
'ابتدا کارت‌های این ستون را به ستون دیگری منتقل کنید',
);
}
const remaining = board.stages.filter((s) => s.id !== stage.id);
await this.prisma.$transaction([
this.prisma.projectBoardStage.delete({ where: { id: stage.id } }),
...remaining.map((s, index) =>
this.prisma.projectBoardStage.update({
where: { id: s.id },
data: { position: index },
}),
),
]);
return this.getOwnedBoardDto(organizationId, board.id);
}
async reorderStages(
organizationId: string,
boardId: string,
stageIds: string[],
): Promise<ProjectBoardDto> {
const board = await this.getOwnedBoard(organizationId, boardId);
const currentIds = board.stages.map((s) => s.id);
const unique = new Set(stageIds);
if (
stageIds.length !== currentIds.length ||
unique.size !== currentIds.length ||
currentIds.some((id) => !unique.has(id))
) {
throw new BadRequestException('ترتیب ستون‌ها نامعتبر است');
}
await this.prisma.$transaction(
stageIds.map((id, index) =>
this.prisma.projectBoardStage.update({
where: { id },
data: { position: index },
}),
),
);
return this.getOwnedBoardDto(organizationId, board.id);
}
private async getOwnedBoard(organizationId: string, boardId: string) {
const board = await this.prisma.projectBoard.findFirst({
where: { id: boardId, organizationId },
include: { stages: { orderBy: { position: 'asc' as const } } },
});
if (!board) throw new NotFoundException('برد پیدا نشد');
return board;
}
private async getOwnedBoardDto(organizationId: string, boardId: string) {
const board = await this.getOwnedBoard(organizationId, boardId);
return this.toBoardDto(board);
}
toCardDto(row: {
id: string;
title: string;
notes: string | null;
tags?: string[];
position: number;
boardId: string;
stageId: string;
createdAt: Date;
updatedAt: Date;
person: { id: string; firstName: string; lastName: string } | null;
company: { id: string; name: string } | null;
deal: { id: string; title: string } | null;
owner: { id: string; fullName: string } | null;
assignees?: Array<{ id: string; fullName: string }>;
}): ProjectCardDto {
return {
id: row.id,
title: row.title,
notes: row.notes,
tags: row.tags ?? [],
position: row.position,
boardId: row.boardId,
stageId: row.stageId,
person: row.person
? {
id: row.person.id,
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
}
: null,
company: row.company,
deal: row.deal,
owner: row.owner,
assignees: row.assignees ?? [],
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private toBoardDto(row: {
id: string;
name: string;
isDefault: boolean;
stages: Array<{ id: string; name: string; position: number }>;
}): ProjectBoardDto {
return {
id: row.id,
name: row.name,
isDefault: row.isDefault,
stages: row.stages.map((s) => ({
id: s.id,
name: s.name,
position: s.position,
})),
};
}
private async assertLinks(
organizationId: string,
actor: CardActor,
personId?: string | null,
companyId?: string | null,
dealId?: string | null,
) {
if (personId) {
const p = await this.prisma.person.findFirst({
where: { id: personId, organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!p) throw new BadRequestException('شخص معتبر نیست');
}
if (companyId) {
const c = await this.prisma.company.findFirst({
where: { id: companyId, organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!c) throw new BadRequestException('شرکت معتبر نیست');
}
if (dealId) {
const d = await this.prisma.deal.findFirst({
where: { id: dealId, organizationId, ...dealAccessWhere(actor) },
select: { id: true },
});
if (!d) throw new BadRequestException('معامله معتبر نیست');
}
}
}
function emptyToNull(v?: string | null) {
if (v == null) return null;
const t = v.trim();
return t ? t : null;
}