import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import type { CreateDealRequest, DealDto, DealLineItemInput, MoveDealRequest, UpdateDealRequest, } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { PipelinesService } from './pipelines.service'; import { toJsonCustomFields } from '../common/custom-fields'; import { NotificationsService } from '../notifications/notifications.service'; import { AutomationsService } from '../automations/automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { WebhooksService } from '../public-api/webhooks.service'; import { type Actor, canReassignOwner, dealAccessWhere, ownerWhere, resolveOwnerId, } from '../common/record-scope'; const dealInclude = { person: { select: { id: true, firstName: true, lastName: true } }, company: { select: { id: true, name: true } }, owner: { select: { id: true, fullName: true } }, createdBy: { select: { id: true, fullName: true } }, assignees: { select: { id: true, fullName: true } }, lineItems: { orderBy: { position: 'asc' as const } }, } satisfies Prisma.DealInclude; type DealActor = Pick< Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' >; @Injectable() export class DealsService { constructor( private readonly prisma: PrismaService, private readonly pipelines: PipelinesService, private readonly notifications: NotificationsService, private readonly automations: AutomationsService, private readonly messageAutomations: MessageAutomationsService, private readonly webhooks: WebhooksService, ) {} async get( organizationId: string, id: string, actor: DealActor, ): Promise { const row = await this.findDeal(organizationId, id, actor); return this.pipelines.toDealDto(row); } async create( organizationId: string, actor: DealActor, dto: CreateDealRequest, ): Promise { const pipeline = dto.pipelineId != null ? await this.prisma.pipeline.findFirst({ where: { id: dto.pipelineId, organizationId }, include: { stages: { orderBy: { position: 'asc' } } }, }) : null; const resolvedPipeline = pipeline ?? (await this.prisma.pipeline.findFirst({ where: { organizationId, isDefault: true }, include: { stages: { orderBy: { position: 'asc' } } }, })); if (!resolvedPipeline) { await this.pipelines.ensureDefaultPipeline(organizationId); return this.create(organizationId, actor, dto); } const stage = (dto.stageId ? resolvedPipeline.stages.find((s) => s.id === dto.stageId) : resolvedPipeline.stages.find((s) => !s.isWon && !s.isLost)) ?? resolvedPipeline.stages[0]; if (!stage) { throw new BadRequestException('مسیر فروش مرحله‌ای ندارد'); } await this.assertLinks(organizationId, actor, dto.personId, dto.companyId); if (dto.lineItems?.length) { await this.assertProducts(organizationId, dto.lineItems); } const assigneeIds = await this.normalizeAssigneeIds( organizationId, dto.assigneeIds, ); const maxPos = await this.prisma.deal.aggregate({ where: { organizationId, stageId: stage.id }, _max: { position: true }, }); const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open'; const amountFromLines = sumLineItems(dto.lineItems); const currency = dto.lineItems?.[0]?.currency?.trim() || (dto.currency ?? 'IRR').trim() || 'IRR'; const created = await this.prisma.deal.create({ data: { organizationId, pipelineId: resolvedPipeline.id, stageId: stage.id, title: dto.title.trim(), amount: amountFromLines != null ? new Prisma.Decimal(amountFromLines) : dto.amount == null || Number.isNaN(Number(dto.amount)) ? null : new Prisma.Decimal(dto.amount), currency, status, source: emptyToNull(dto.source), notes: emptyToNull(dto.notes), tags: normalizeTags(dto.tags), isStale: dto.isStale ?? false, isRotten: dto.isRotten ?? false, customFields: toJsonCustomFields(dto.customFields), expectedCloseAt: dto.expectedCloseAt ? new Date(dto.expectedCloseAt) : null, code: emptyToNull(dto.code), probability: normalizeProbability(dto.probability), sourceDetail: emptyToNull(dto.sourceDetail), referrer: emptyToNull(dto.referrer), lostReason: emptyToNull(dto.lostReason), lostNote: emptyToNull(dto.lostNote), closedAt: dto.closedAt ? new Date(dto.closedAt) : status !== 'open' ? new Date() : null, createdById: actor.userId, personId: emptyToNull(dto.personId ?? null), companyId: emptyToNull(dto.companyId ?? null), ownerId: resolveOwnerId(actor, dto.ownerId), position: (maxPos._max.position ?? -1) + 1, ...(assigneeIds.length ? { assignees: { connect: assigneeIds.map((id) => ({ id })) } } : {}), ...(dto.lineItems?.length ? { lineItems: { create: dto.lineItems.map((item, index) => ({ productId: emptyToNull(item.productId ?? null), name: item.name.trim(), quantity: new Prisma.Decimal(item.quantity), unitPrice: new Prisma.Decimal(item.unitPrice), currency: (item.currency ?? currency).trim() || 'IRR', position: index, })), }, } : {}), }, include: dealInclude, }); const createdCtx = { organizationId, entityType: 'deal' as const, entityId: created.id, title: created.title, personId: created.personId, companyId: created.companyId, dealId: created.id, ownerId: created.ownerId, }; await this.automations.runTrigger('deal_created', createdCtx); await this.messageAutomations.onEvent('deal_created', createdCtx); this.webhooks.emit(organizationId, 'deal.created', { id: created.id, title: created.title, status: created.status, amount: created.amount == null ? null : Number(created.amount), currency: created.currency, stageId: created.stageId, pipelineId: created.pipelineId, }); return this.pipelines.toDealDto(created); } async update( organizationId: string, id: string, actor: DealActor, dto: UpdateDealRequest, ): Promise { await this.ensureExists(organizationId, id, actor); await this.assertLinks(organizationId, actor, dto.personId, dto.companyId); if (dto.lineItems) { await this.assertProducts(organizationId, dto.lineItems); } const data: Prisma.DealUncheckedUpdateInput = {}; if (dto.title !== undefined) data.title = dto.title.trim(); if ('amount' in dto && dto.lineItems === undefined) { data.amount = dto.amount == null || Number.isNaN(Number(dto.amount)) ? null : new Prisma.Decimal(dto.amount); } if (dto.currency !== undefined && dto.lineItems === undefined) { data.currency = dto.currency.trim() || 'IRR'; } if ('source' in dto) data.source = emptyToNull(dto.source); if ('notes' in dto) data.notes = emptyToNull(dto.notes); if (dto.tags !== undefined) data.tags = normalizeTags(dto.tags); if (dto.isStale !== undefined) data.isStale = dto.isStale; if (dto.isRotten !== undefined) data.isRotten = dto.isRotten; if ('customFields' in dto) { data.customFields = toJsonCustomFields(dto.customFields); } if ('lostReason' in dto) data.lostReason = emptyToNull(dto.lostReason); if ('lostNote' in dto) data.lostNote = emptyToNull(dto.lostNote); if ('code' in dto) data.code = emptyToNull(dto.code); if ('probability' in dto) data.probability = normalizeProbability(dto.probability); if ('sourceDetail' in dto) data.sourceDetail = emptyToNull(dto.sourceDetail); if ('referrer' in dto) data.referrer = emptyToNull(dto.referrer); if ('closedAt' in dto) data.closedAt = dto.closedAt ? new Date(dto.closedAt) : null; if ('expectedCloseAt' in dto) { data.expectedCloseAt = dto.expectedCloseAt ? new Date(dto.expectedCloseAt) : null; } if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null); if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); if (dto.ownerId !== undefined && canReassignOwner(actor.role)) { data.ownerId = resolveOwnerId(actor, dto.ownerId); } let assigneeConnect: { id: string }[] | undefined; if (dto.assigneeIds !== undefined) { const assigneeIds = await this.normalizeAssigneeIds( organizationId, dto.assigneeIds, ); assigneeConnect = assigneeIds.map((uid) => ({ id: uid })); } if (dto.lineItems !== undefined) { const total = sumLineItems(dto.lineItems); data.amount = total == null ? null : new Prisma.Decimal(total); if (dto.lineItems[0]?.currency) { data.currency = dto.lineItems[0].currency.trim() || 'IRR'; } } await this.prisma.$transaction(async (tx) => { if (dto.lineItems !== undefined) { await tx.dealLineItem.deleteMany({ where: { dealId: id } }); if (dto.lineItems.length) { const currency = (typeof data.currency === 'string' ? data.currency : null) || 'IRR'; await tx.dealLineItem.createMany({ data: dto.lineItems.map((item, index) => ({ dealId: id, productId: emptyToNull(item.productId ?? null), name: item.name.trim(), quantity: new Prisma.Decimal(item.quantity), unitPrice: new Prisma.Decimal(item.unitPrice), currency: (item.currency ?? currency).trim() || 'IRR', position: index, })), }); } } await tx.deal.update({ where: { id }, data: { ...data, ...(assigneeConnect ? { assignees: { set: assigneeConnect } } : {}), }, }); }); const updated = await this.get(organizationId, id, actor); this.webhooks.emit(organizationId, 'deal.updated', { id: updated.id, title: updated.title, status: updated.status, amount: updated.amount, currency: updated.currency, stageId: updated.stageId, pipelineId: updated.pipelineId, }); return updated; } async move( organizationId: string, id: string, actor: DealActor, dto: MoveDealRequest, ): Promise { const deal = await this.prisma.deal.findFirst({ where: { id, organizationId, ...dealAccessWhere(actor) }, select: { id: true, title: true, pipelineId: true, stageId: true, ownerId: true, status: true, personId: true, companyId: true, }, }); if (!deal) throw new NotFoundException('معامله پیدا نشد'); const stage = await this.prisma.pipelineStage.findFirst({ where: { id: dto.stageId, pipelineId: deal.pipelineId }, }); if (!stage) { throw new BadRequestException('مرحله در این مسیر فروش معتبر نیست'); } const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open'; const position = dto.position ?? (( await this.prisma.deal.aggregate({ where: { organizationId, stageId: stage.id, NOT: { id } }, _max: { position: true }, }) )._max.position ?? -1) + 1; const row = await this.prisma.deal.update({ where: { id }, data: { stageId: stage.id, position, status, lostReason: stage.isLost ? undefined : null, ...(status !== deal.status ? { closedAt: status === 'open' ? null : new Date() } : {}), }, include: dealInclude, }); if (deal.ownerId && deal.stageId !== stage.id) { await this.notifications.create({ organizationId, userId: deal.ownerId, type: 'deal_moved', title: 'جابه‌جایی معامله در مسیر فروش', body: `«${deal.title}» به مرحله «${stage.name}» منتقل شد`, link: `/deals/${deal.id}`, entityType: 'deal', entityId: deal.id, }); } if (deal.stageId !== stage.id) { const ctx = { organizationId, entityType: 'deal' as const, entityId: deal.id, title: deal.title, personId: deal.personId, companyId: deal.companyId, dealId: deal.id, ownerId: deal.ownerId, }; await this.automations.runTrigger('deal_stage_changed', ctx); await this.messageAutomations.onEvent('deal_stage_changed', ctx); if (status === 'won' && deal.status !== 'won') { await this.automations.runTrigger('deal_won', ctx); await this.messageAutomations.onEvent('deal_won', ctx); this.webhooks.emit(organizationId, 'deal.won', { id: deal.id, title: deal.title, stageId: dto.stageId, }); } if (status === 'lost' && deal.status !== 'lost') { await this.automations.runTrigger('deal_lost', ctx); await this.messageAutomations.onEvent('deal_lost', ctx); this.webhooks.emit(organizationId, 'deal.lost', { id: deal.id, title: deal.title, stageId: dto.stageId, }); } } return this.pipelines.toDealDto(row); } async remove( organizationId: string, id: string, actor: DealActor, ): Promise { await this.ensureExists(organizationId, id, actor); await this.prisma.deal.delete({ where: { id } }); } private async ensureExists( organizationId: string, id: string, actor: DealActor, ) { const found = await this.prisma.deal.findFirst({ where: { id, organizationId, ...dealAccessWhere(actor) }, select: { id: true }, }); if (!found) throw new NotFoundException('معامله پیدا نشد'); } private async findDeal( organizationId: string, id: string, actor: DealActor, ) { const row = await this.prisma.deal.findFirst({ where: { id, organizationId, ...dealAccessWhere(actor) }, include: dealInclude, }); if (!row) throw new NotFoundException('معامله پیدا نشد'); return row; } private async normalizeAssigneeIds( organizationId: string, raw?: string[] | null, ): Promise { const ids = [ ...new Set((raw ?? []).map((id) => id.trim()).filter(Boolean)), ]; if (!ids.length) return []; const count = await this.prisma.user.count({ where: { organizationId, id: { in: ids }, isActive: true }, }); if (count !== ids.length) { throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست'); } return ids; } private async assertProducts( organizationId: string, items: DealLineItemInput[], ) { const ids = [ ...new Set( items .map((i) => emptyToNull(i.productId ?? null)) .filter((id): id is string => Boolean(id)), ), ]; if (!ids.length) return; const count = await this.prisma.product.count({ where: { organizationId, id: { in: ids } }, }); if (count !== ids.length) { throw new BadRequestException('یکی از محصولات انتخاب‌شده معتبر نیست'); } } private async assertLinks( organizationId: string, actor: DealActor, personId?: string | null, companyId?: string | null, ) { const pId = emptyToNull(personId ?? null); const cId = emptyToNull(companyId ?? null); const scope = ownerWhere(actor); if (pId) { const person = await this.prisma.person.findFirst({ where: { id: pId, organizationId, ...scope }, select: { id: true }, }); if (!person) throw new BadRequestException('شخص انتخاب‌شده معتبر نیست'); } if (cId) { const company = await this.prisma.company.findFirst({ where: { id: cId, organizationId, ...scope }, select: { id: true }, }); if (!company) throw new BadRequestException('شرکت انتخاب‌شده معتبر نیست'); } } } function emptyToNull(value?: string | null): string | null { if (value == null) return null; const trimmed = value.trim(); return trimmed.length ? trimmed : null; } function normalizeProbability(value?: number | null): number | null { if (value == null || Number.isNaN(Number(value))) return null; return Math.max(0, Math.min(100, Math.round(Number(value)))); } function normalizeTags(tags?: string[]): string[] { if (!tags?.length) return []; const seen = new Set(); const out: string[] = []; for (const raw of tags) { const tag = raw.trim(); if (!tag) continue; const key = tag.toLowerCase(); if (seen.has(key)) continue; seen.add(key); out.push(tag.slice(0, 40)); } return out.slice(0, 20); } function sumLineItems(items?: DealLineItemInput[]): number | null { if (!items?.length) return null; return items.reduce( (sum, item) => sum + Number(item.quantity) * Number(item.unitPrice), 0, ); }