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>
556 lines
17 KiB
TypeScript
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;
|
|
}
|