391 lines
12 KiB
TypeScript
391 lines
12 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,
|
||
|
|
canReassignOwner,
|
||
|
|
ownerWhere,
|
||
|
|
resolveOwnerId,
|
||
|
|
} from '../common/record-scope';
|
||
|
|
|
||
|
|
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 } },
|
||
|
|
} satisfies Prisma.ProjectCardInclude;
|
||
|
|
|
||
|
|
type CardActor = Pick<Actor, 'userId' | 'role'>;
|
||
|
|
|
||
|
|
@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 ? ownerWhere(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, ...ownerWhere(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 created = await this.prisma.projectCard.create({
|
||
|
|
data: {
|
||
|
|
organizationId,
|
||
|
|
boardId: resolved.id,
|
||
|
|
stageId: stage.id,
|
||
|
|
title: dto.title.trim(),
|
||
|
|
notes: emptyToNull(dto.notes),
|
||
|
|
personId: emptyToNull(dto.personId),
|
||
|
|
companyId: emptyToNull(dto.companyId),
|
||
|
|
dealId: emptyToNull(dto.dealId),
|
||
|
|
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||
|
|
position: (maxPos._max.position ?? -1) + 1,
|
||
|
|
},
|
||
|
|
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, ...ownerWhere(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 ('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) {
|
||
|
|
if (!canReassignOwner(actor.role) && dto.ownerId !== existing.ownerId) {
|
||
|
|
throw new BadRequestException('تغییر مسئول مجاز نیست');
|
||
|
|
}
|
||
|
|
data.ownerId = emptyToNull(dto.ownerId ?? null);
|
||
|
|
}
|
||
|
|
|
||
|
|
const row = await this.prisma.projectCard.update({
|
||
|
|
where: { id },
|
||
|
|
data,
|
||
|
|
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, ...ownerWhere(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, ...ownerWhere(actor) },
|
||
|
|
select: { id: true },
|
||
|
|
});
|
||
|
|
if (!found) throw new NotFoundException('کارت پیدا نشد');
|
||
|
|
await this.prisma.projectCard.delete({ where: { id } });
|
||
|
|
}
|
||
|
|
|
||
|
|
toCardDto(row: {
|
||
|
|
id: string;
|
||
|
|
title: string;
|
||
|
|
notes: string | null;
|
||
|
|
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;
|
||
|
|
}): ProjectCardDto {
|
||
|
|
return {
|
||
|
|
id: row.id,
|
||
|
|
title: row.title,
|
||
|
|
notes: row.notes,
|
||
|
|
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,
|
||
|
|
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, ...ownerWhere(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;
|
||
|
|
}
|