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 { 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 { 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 { 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 { 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(); 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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; }