import { BadRequestException, ConflictException, Injectable, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { parseBirthDate } from '../common/excel'; import * as path from 'node:path'; import { randomUUID } from 'node:crypto'; import type { ConvertLeadResult, FilteredIdsResponse, LeadBoardColumnDto, LeadDto, LeadDuplicateCheckResult, LeadEventDto, LeadStatsDto, PaginatedResponse, } from '@mento/shared'; import { FILTERED_IDS_MAX } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import { SmsService } from '../common/sms.service'; import { renderSmsText } from '../common/sms-text'; import { type Actor, elevated, forScope, } from '../common/record-scope'; import { asAddresses, collectAddresses, toJsonAddresses, } from '../common/custom-fields'; import { emptyToNull, normalizePhone } from '../contacts/normalize'; import { LocalFileStorage } from '../storage/local-file.storage'; import { LeadStagesService } from './lead-stages.service'; import { buildSearchWhere, ci } from '../common/search'; import { GamificationService } from '../gamification/gamification.service'; const LOGO_MAX_BYTES = 2 * 1024 * 1024; const LOGO_EXT: Record = { 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', }; function mobileKey(raw: string | null | undefined): string | null { const n = normalizePhone(raw); if (!n) return null; const d = n.replace(/\D/g, ''); if (!d) return null; if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`; if (d.length === 10 && d.startsWith('9')) return `0${d}`; return d.startsWith('0') ? d : d; } function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput { const actor = forScope(baseActor, 'leads'); if (elevated(actor.role)) return {}; if (actor.dataVisibility === 'org') return {}; if ( actor.dataVisibility === 'team' && actor.visibleUserIds && actor.visibleUserIds.length ) { return { assigneeId: { in: actor.visibleUserIds } }; } return { assigneeId: actor.userId }; } const leadInclude = { stage: true, assignee: { select: { id: true, fullName: true } }, } as const; type LeadRow = Prisma.LeadGetPayload<{ include: typeof leadInclude }>; @Injectable() export class LeadsService { constructor( private readonly prisma: PrismaService, private readonly stages: LeadStagesService, private readonly files: LocalFileStorage, private readonly game: GamificationService, private readonly sms: SmsService, ) {} private gameEvent( actor: Actor, event: Parameters[0]['event'], lead: { id: string; title: string }, extra: { userId?: string | null; eventKey?: string; facts?: Record } = {}, ) { this.game.emit({ organizationId: actor.organizationId, userId: extra.userId ?? actor.userId, event, entityType: 'lead', entityId: lead.id, entityTitle: lead.title, eventKey: extra.eventKey, facts: extra.facts, }); } private leadListWhere( actor: Actor, opts: { q?: string; stageId?: string; assigneeId?: string; city?: string; region?: string; trade?: string; source?: string; unassigned?: boolean; archived?: boolean; hasMobile?: boolean; }, ): Prisma.LeadWhereInput { const search = buildSearchWhere(opts.q, (t) => [ { title: ci(t) }, { contactName: ci(t) }, { mobile: { contains: t } }, { phone: { contains: t } }, { city: ci(t) }, { region: ci(t) }, { trade: ci(t) }, { instagram: ci(t) }, { source: ci(t) }, ]); const and: Prisma.LeadWhereInput[] = []; if (search) and.push(search); if (opts.hasMobile) { and.push({ mobile: { not: '' } }); } return { organizationId: actor.organizationId, ...leadAccessWhere(actor), archivedAt: opts.archived ? { not: null } : null, ...(opts.stageId ? { stageId: opts.stageId } : {}), ...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}), ...(opts.unassigned ? { assigneeId: null } : {}), ...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}), ...(opts.region ? { region: { contains: opts.region, mode: 'insensitive' } } : {}), ...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}), ...(opts.source ? { source: { contains: opts.source, mode: 'insensitive' } } : {}), ...(and.length ? { AND: and } : {}), }; } async list( actor: Actor, opts: { q?: string; page?: number; limit?: number; stageId?: string; assigneeId?: string; city?: string; region?: string; trade?: string; source?: string; unassigned?: boolean; archived?: boolean; sort?: string; hasMobile?: boolean; }, ): Promise> { await this.stages.ensureDefaults(actor.organizationId); const page = Math.max(1, opts.page ?? 1); const limit = Math.min(100, Math.max(1, opts.limit ?? 20)); const where = this.leadListWhere(actor, opts); const orderBy: Prisma.LeadOrderByWithRelationInput = opts.sort === 'follow_up' ? { nextFollowUpAt: 'asc' } : opts.sort === 'last_contact' ? { lastContactAt: 'desc' } : opts.sort === 'newest' ? { createdAt: 'desc' } : { updatedAt: 'desc' }; const [total, rows] = await Promise.all([ this.prisma.lead.count({ where }), this.prisma.lead.findMany({ where, include: leadInclude, orderBy, skip: (page - 1) * limit, take: limit, }), ]); return { items: rows.map((r) => this.toDto(r)), total, page, limit, }; } async listIds( actor: Actor, opts: { q?: string; stageId?: string; assigneeId?: string; city?: string; region?: string; trade?: string; source?: string; unassigned?: boolean; archived?: boolean; hasMobile?: boolean; }, max = FILTERED_IDS_MAX, ): Promise { await this.stages.ensureDefaults(actor.organizationId); const where = this.leadListWhere(actor, opts); const total = await this.prisma.lead.count({ where }); if (total > max) { throw new BadRequestException( `تعداد نتایج (${total}) از سقف مجاز بیشتر است؛ فیلتر را محدودتر کنید.`, ); } const rows = await this.prisma.lead.findMany({ where, select: { id: true }, orderBy: { updatedAt: 'desc' }, take: max, }); return { ids: rows.map((r) => r.id), total }; } async board(actor: Actor): Promise { const stages = await this.stages.list(actor.organizationId); const boardStages = stages.filter((s) => s.showOnBoard); const whereBase = { organizationId: actor.organizationId, ...leadAccessWhere(actor), archivedAt: null, }; const perColumn = 120; return Promise.all( boardStages.map(async (stage) => { const where = { ...whereBase, stageId: stage.id }; const [total, rows] = await Promise.all([ this.prisma.lead.count({ where }), this.prisma.lead.findMany({ where, include: leadInclude, orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }], take: perColumn, }), ]); return { stage, total, leads: rows.map((row) => this.toDto(row)), }; }), ); } async get(actor: Actor, id: string): Promise { const row = await this.findAccessible(actor, id); const events = await this.prisma.leadEvent.findMany({ where: { leadId: id }, orderBy: { createdAt: 'desc' }, take: 100, }); return { ...this.toDto(row), events: events.map((e) => ({ id: e.id, type: e.type, title: e.title, body: e.body, actorId: e.actorId, createdAt: e.createdAt.toISOString(), })), }; } async checkDuplicate( actor: Actor, mobileRaw: string, ): Promise { const mobile = mobileKey(mobileRaw); if (!mobile) { return { exists: false, kind: null, id: null, title: null }; } const lead = await this.prisma.lead.findFirst({ where: { organizationId: actor.organizationId, mobile }, select: { id: true, title: true }, }); if (lead) { return { exists: true, kind: 'lead', id: lead.id, title: lead.title, }; } const person = await this.prisma.person.findFirst({ where: { organizationId: actor.organizationId, OR: [{ mobile }, { phone: mobile }], }, select: { id: true, firstName: true, lastName: true }, }); if (person) { return { exists: true, kind: 'person', id: person.id, title: `${person.firstName} ${person.lastName}`.trim(), }; } return { exists: false, kind: null, id: null, title: null }; } async create( actor: Actor, dto: { title: string; mobile: string; contactName?: string | null; phone?: string | null; city?: string | null; region?: string | null; instagram?: string | null; birthDate?: string | null; addresses?: string[]; trade?: string | null; source?: string | null; notes?: string | null; estimatedValue?: number | null; stageId?: string; assigneeId?: string | null; nextFollowUpAt?: string | null; registeredAt?: string | null; }, ): Promise { await this.stages.ensureDefaults(actor.organizationId); const mobile = mobileKey(dto.mobile); if (!mobile) throw new BadRequestException('شماره موبایل معتبر نیست'); const dup = await this.checkDuplicate(actor, mobile); if (dup.exists) { throw new ConflictException( dup.kind === 'person' ? `این شماره قبلاً به‌عنوان مشتری ثبت شده است (${dup.title})` : `این شماره قبلاً به‌عنوان لید ثبت شده است (${dup.title})`, ); } const stages = await this.stages.list(actor.organizationId); const stageId = dto.stageId || stages[0]?.id; if (!stageId) throw new BadRequestException('مرحله‌ای تعریف نشده'); let assigneeId: string | null = null; if (elevated(actor.role) && dto.assigneeId) { assigneeId = dto.assigneeId; } else if (!elevated(actor.role)) { assigneeId = actor.userId; } const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null; const row = await this.prisma.lead.create({ data: { organizationId: actor.organizationId, title: dto.title.trim(), contactName: emptyToNull(dto.contactName), mobile, phone: emptyToNull(dto.phone), city: emptyToNull(dto.city), region: emptyToNull(dto.region), instagram: emptyToNull(dto.instagram), birthDate: parseBirthDate(dto.birthDate), addresses: toJsonAddresses(dto.addresses), trade: emptyToNull(dto.trade), source: emptyToNull(dto.source), notes: emptyToNull(dto.notes), estimatedValue: dto.estimatedValue != null ? new Prisma.Decimal(dto.estimatedValue) : null, ...(registeredAt && !Number.isNaN(registeredAt.getTime()) ? { createdAt: registeredAt } : {}), stageId, assigneeId, createdById: actor.userId, nextFollowUpAt: dto.nextFollowUpAt ? new Date(dto.nextFollowUpAt) : null, }, include: leadInclude, }); await this.addEvent(actor, row.id, 'created', 'لید ایجاد شد', null); if (assigneeId) { await this.addEvent( actor, row.id, 'assigned', `به کارشناس اختصاص داده شد`, null, { assigneeId }, ); } this.gameEvent(actor, 'LEAD_CREATED', row, { facts: { source: row.source, amount: row.estimatedValue == null ? null : Number(row.estimatedValue), }, }); return this.toDto(row); } async update( actor: Actor, id: string, dto: Record, ): Promise { const existing = await this.findAccessible(actor, id); if (existing.archivedAt) { throw new BadRequestException('لید بایگانی‌شده قابل ویرایش نیست'); } let mobile = existing.mobile; if (typeof dto.mobile === 'string') { const next = mobileKey(dto.mobile); if (!next) throw new BadRequestException('شماره موبایل معتبر نیست'); if (next !== existing.mobile) { const dup = await this.checkDuplicate(actor, next); if (dup.exists && dup.id !== id) { throw new ConflictException( dup.kind === 'person' ? 'این شماره متعلق به یک مشتری است' : 'این شماره متعلق به لید دیگری است', ); } mobile = next; } } const data: Prisma.LeadUpdateInput = { ...(typeof dto.title === 'string' ? { title: dto.title.trim() } : {}), mobile, ...(dto.contactName !== undefined ? { contactName: emptyToNull(dto.contactName as string) } : {}), ...(dto.phone !== undefined ? { phone: emptyToNull(dto.phone as string) } : {}), ...(dto.city !== undefined ? { city: emptyToNull(dto.city as string) } : {}), ...(dto.region !== undefined ? { region: emptyToNull(dto.region as string) } : {}), ...(dto.instagram !== undefined ? { instagram: emptyToNull(dto.instagram as string) } : {}), ...(dto.birthDate !== undefined ? { birthDate: parseBirthDate(dto.birthDate as string | null) } : {}), ...(dto.addresses !== undefined ? { addresses: toJsonAddresses( Array.isArray(dto.addresses) ? (dto.addresses as string[]) : [], ), } : {}), ...(dto.trade !== undefined ? { trade: emptyToNull(dto.trade as string) } : {}), ...(dto.source !== undefined ? { source: emptyToNull(dto.source as string) } : {}), ...(dto.notes !== undefined ? { notes: emptyToNull(dto.notes as string) } : {}), ...(dto.estimatedValue !== undefined ? { estimatedValue: dto.estimatedValue == null ? null : new Prisma.Decimal(Number(dto.estimatedValue)), } : {}), ...(dto.nextFollowUpAt !== undefined ? { nextFollowUpAt: dto.nextFollowUpAt ? new Date(String(dto.nextFollowUpAt)) : null, } : {}), ...(dto.lastContactAt !== undefined ? { lastContactAt: dto.lastContactAt ? new Date(String(dto.lastContactAt)) : null, } : {}), }; if (typeof dto.stageId === 'string' && dto.stageId !== existing.stageId) { data.stage = { connect: { id: dto.stageId } }; } if (elevated(actor.role) && dto.assigneeId !== undefined) { const aid = dto.assigneeId ? String(dto.assigneeId) : null; data.assignee = aid ? { connect: { id: aid } } : { disconnect: true }; } const row = await this.prisma.lead.update({ where: { id }, data, include: leadInclude, }); if (typeof dto.stageId === 'string' && dto.stageId !== existing.stageId) { await this.addEvent( actor, id, 'stage_changed', `وضعیت به «${row.stage.name}» تغییر کرد`, ); this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, { eventKey: `${id}:${row.stageId}`, facts: { stageName: row.stage.name, source: row.source }, }); } return this.toDto(row); } async move( actor: Actor, id: string, stageId: string, position?: number, ): Promise { const before = await this.findAccessible(actor, id); const stage = await this.prisma.leadStage.findFirst({ where: { id: stageId, organizationId: actor.organizationId }, }); if (!stage) throw new NotFoundException('مرحله پیدا نشد'); const row = await this.prisma.lead.update({ where: { id }, data: { stageId, position: position ?? 0, ...(stage.isConverted ? {} : {}), }, include: leadInclude, }); await this.addEvent( actor, id, 'stage_changed', `به مرحله «${stage.name}» منتقل شد`, ); if (before.stageId !== stageId) { this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, { eventKey: `${id}:${stageId}`, facts: { stageName: stage.name, source: row.source }, }); } return this.toDto(row); } async assign( actor: Actor, leadIds: string[], assigneeId: string, ): Promise { if (!elevated(actor.role)) { throw new BadRequestException('فقط مدیر می‌تواند تخصیص دهد'); } const user = await this.prisma.user.findFirst({ where: { id: assigneeId, organizationId: actor.organizationId }, }); if (!user) throw new NotFoundException('کارشناس پیدا نشد'); const result = await this.prisma.lead.updateMany({ where: { organizationId: actor.organizationId, id: { in: leadIds }, archivedAt: null, }, data: { assigneeId }, }); for (const leadId of leadIds) { await this.addEvent( actor, leadId, 'assigned', `به «${user.fullName}» اختصاص داده شد`, null, { assigneeId }, ); } return result.count; } async bulkStage( actor: Actor, leadIds: string[], stageId: string, ): Promise { if (!elevated(actor.role)) { throw new BadRequestException('فقط مدیر می‌تواند وضعیت گروهی تغییر دهد'); } const stage = await this.prisma.leadStage.findFirst({ where: { id: stageId, organizationId: actor.organizationId }, }); if (!stage) throw new NotFoundException('مرحله پیدا نشد'); const result = await this.prisma.lead.updateMany({ where: { organizationId: actor.organizationId, id: { in: leadIds }, archivedAt: null, }, data: { stageId }, }); for (const leadId of leadIds) { await this.addEvent( actor, leadId, 'stage_changed', `وضعیت گروهی به «${stage.name}»`, ); } return result.count; } async autoAssign( actor: Actor, opts: { rule?: string; leadIds?: string[]; assigneeIds?: string[]; onlyUnassigned?: boolean; }, ): Promise<{ assigned: number }> { if (!elevated(actor.role)) { throw new BadRequestException('فقط مدیر می‌تواند تخصیص خودکار کند'); } const rule = opts.rule === 'equal_split' ? 'equal_split' : 'round_robin'; let assignees = opts.assigneeIds?.length ? await this.prisma.user.findMany({ where: { organizationId: actor.organizationId, id: { in: opts.assigneeIds }, isActive: true, }, select: { id: true, fullName: true }, }) : await this.prisma.user.findMany({ where: { organizationId: actor.organizationId, isActive: true, memberships: { some: { organizationId: actor.organizationId, isActive: true, role: { in: ['sales', 'admin', 'owner'] }, }, }, }, select: { id: true, fullName: true }, }); assignees = assignees.filter((u) => u.id); if (!assignees.length) { throw new BadRequestException('کارشناسی برای تخصیص پیدا نشد'); } const leads = await this.prisma.lead.findMany({ where: { organizationId: actor.organizationId, archivedAt: null, ...(opts.leadIds?.length ? { id: { in: opts.leadIds } } : {}), ...(opts.onlyUnassigned !== false ? { assigneeId: null } : {}), }, orderBy: { createdAt: 'asc' }, select: { id: true }, }); if (!leads.length) return { assigned: 0 }; let assigned = 0; if (rule === 'equal_split') { const chunk = Math.ceil(leads.length / assignees.length); for (let i = 0; i < assignees.length; i += 1) { const slice = leads.slice(i * chunk, (i + 1) * chunk); if (!slice.length) continue; await this.assign( actor, slice.map((l) => l.id), assignees[i].id, ); assigned += slice.length; } } else { for (let i = 0; i < leads.length; i += 1) { const user = assignees[i % assignees.length]; await this.prisma.lead.update({ where: { id: leads[i].id }, data: { assigneeId: user.id }, }); await this.addEvent( actor, leads[i].id, 'assigned', `تخصیص خودکار (نوبتی) به «${user.fullName}»`, null, { assigneeId: user.id }, ); assigned += 1; } } return { assigned }; } async logCall( actor: Actor, id: string, dto: { outcome?: string; body?: string; nextFollowUpAt?: string | null; stageId?: string; }, ): Promise { const before = await this.findAccessible(actor, id); const title = dto.outcome ? `تماس ثبت شد — ${dto.outcome}` : 'تماس ثبت شد'; await this.addEvent(actor, id, 'call', title, dto.body ?? null); const data: Prisma.LeadUpdateInput = { lastContactAt: new Date(), ...(dto.nextFollowUpAt !== undefined ? { nextFollowUpAt: dto.nextFollowUpAt ? new Date(dto.nextFollowUpAt) : null, } : {}), }; if (dto.stageId) data.stage = { connect: { id: dto.stageId } }; const row = await this.prisma.lead.update({ where: { id }, data, include: leadInclude, }); this.gameEvent(actor, 'CALL_LOGGED', row, { eventKey: randomUUID(), facts: { hasDescription: !!dto.body?.trim() }, }); if (dto.stageId && dto.stageId !== before.stageId) { this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, { eventKey: `${id}:${dto.stageId}`, facts: { stageName: row.stage.name, source: row.source }, }); } return this.toDto(row); } async addNote(actor: Actor, id: string, body: string): Promise { await this.findAccessible(actor, id); await this.addEvent(actor, id, 'note', 'یادداشت', body); const row = await this.findAccessible(actor, id); this.gameEvent(actor, 'NOTE_CREATED', row, { eventKey: randomUUID(), facts: { hasDescription: body.trim().length > 0 }, }); return this.toDto(row); } async setFollowUp( actor: Actor, id: string, nextFollowUpAt: string, body?: string, ): Promise { await this.findAccessible(actor, id); const at = new Date(nextFollowUpAt); await this.addEvent( actor, id, 'follow_up', 'پیگیری بعدی ثبت شد', body ?? null, { nextFollowUpAt: at.toISOString() }, ); const row = await this.prisma.lead.update({ where: { id }, data: { nextFollowUpAt: at }, include: leadInclude, }); this.gameEvent(actor, 'FOLLOW_UP_CREATED', row, { eventKey: randomUUID(), facts: { hasDescription: !!body?.trim() }, }); return this.toDto(row); } async archive(actor: Actor, ids: string[]): Promise { if (!elevated(actor.role) && ids.length > 1) { throw new BadRequestException('حذف گروهی فقط برای مدیر'); } for (const id of ids) { await this.findAccessible(actor, id); await this.addEvent(actor, id, 'archived', 'لید بایگانی شد'); } const result = await this.prisma.lead.updateMany({ where: { organizationId: actor.organizationId, id: { in: ids }, }, data: { archivedAt: new Date() }, }); return result.count; } async convert( actor: Actor, id: string, dto: { firstName?: string; lastName?: string }, ): Promise { const lead = await this.findAccessible(actor, id); if (lead.convertedPersonId) { return { lead: this.toDto(lead), personId: lead.convertedPersonId, createdPerson: false, }; } let person = await this.prisma.person.findFirst({ where: { organizationId: actor.organizationId, OR: [{ mobile: lead.mobile }, { phone: lead.mobile }], }, }); let createdPerson = false; if (!person) { const rawName = dto.firstName || dto.lastName ? `${dto.firstName ?? ''} ${dto.lastName ?? ''}`.trim() : (lead.contactName || lead.title).trim(); const nameParts = rawName.split(/\s+/).filter(Boolean); const firstName = nameParts[0] || lead.title; const lastName = nameParts.slice(1).join(' ') || '—'; person = await this.prisma.person.create({ data: { organizationId: actor.organizationId, firstName, lastName, mobile: lead.mobile, phone: lead.phone, instagram: lead.instagram, birthDate: lead.birthDate, addresses: toJsonAddresses(asAddresses(lead.addresses)), notes: [ lead.notes, lead.city ? `شهر: ${lead.city}` : null, lead.trade ? `صنف: ${lead.trade}` : null, lead.source ? `منبع لید: ${lead.source}` : null, `تبدیل از لید: ${lead.title}`, ] .filter(Boolean) .join('\n'), ownerId: lead.assigneeId ?? actor.userId, createdAt: lead.createdAt, importSource: 'lead_form', sourceRef: lead.id, }, }); createdPerson = true; } else if (!person.birthDate && lead.birthDate) { await this.prisma.person.update({ where: { id: person.id }, data: { birthDate: lead.birthDate }, }); } const convertedStage = await this.prisma.leadStage.findFirst({ where: { organizationId: actor.organizationId, isConverted: true }, orderBy: { position: 'asc' }, }); const row = await this.prisma.lead.update({ where: { id }, data: { convertedPersonId: person.id, convertedAt: new Date(), ...(convertedStage ? { stageId: convertedStage.id } : {}), }, include: leadInclude, }); await this.addEvent( actor, id, 'converted', `تبدیل به مشتری انجام شد`, null, { personId: person.id }, ); await this.prisma.activity .create({ data: { organizationId: actor.organizationId, title: `لید تبدیل‌شده: ${lead.title}`, type: 'note', status: 'done', priority: 'medium', description: `این مشتری از لید «${lead.title}» تبدیل شده است.`, personId: person.id, assigneeId: lead.assigneeId ?? actor.userId, createdById: actor.userId, tags: ['lead-convert'], dueAt: new Date(), }, }) .catch(() => undefined); this.gameEvent(actor, 'LEAD_CONVERTED', row, { userId: lead.assigneeId ?? actor.userId, facts: { source: lead.source, amount: lead.estimatedValue == null ? null : Number(lead.estimatedValue), }, }); return { lead: this.toDto(row), personId: person.id, createdPerson, }; } async stats(actor: Actor): Promise { await this.stages.ensureDefaults(actor.organizationId); const base: Prisma.LeadWhereInput = { organizationId: actor.organizationId, ...leadAccessWhere(actor), archivedAt: null, }; const stages = await this.stages.list(actor.organizationId); const convertedIds = stages.filter((s) => s.isConverted).map((s) => s.id); const lostIds = stages.filter((s) => s.isLost).map((s) => s.id); const interested = stages.find((s) => s.name.includes('علاقه‌مند')); const newStage = stages.find((s) => s.name === 'جدید'); const [ total, newCount, unassigned, converted, lost, interestedCount, withFollowUp, callsLogged, byAssigneeRaw, ] = await Promise.all([ this.prisma.lead.count({ where: base }), newStage ? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } }) : Promise.resolve(0), this.prisma.lead.count({ where: { ...base, assigneeId: null } }), convertedIds.length ? this.prisma.lead.count({ where: { ...base, stageId: { in: convertedIds } }, }) : Promise.resolve(0), lostIds.length ? this.prisma.lead.count({ where: { ...base, stageId: { in: lostIds } }, }) : Promise.resolve(0), interested ? this.prisma.lead.count({ where: { ...base, stageId: interested.id }, }) : Promise.resolve(0), this.prisma.lead.count({ where: { ...base, nextFollowUpAt: { not: null } }, }), this.prisma.leadEvent.count({ where: { organizationId: actor.organizationId, type: 'call', }, }), this.prisma.lead.groupBy({ by: ['assigneeId'], where: { ...base, assigneeId: { not: null } }, _count: { _all: true }, }), ]); const userIds = byAssigneeRaw .map((r) => r.assigneeId) .filter((id): id is string => Boolean(id)); const users = userIds.length ? await this.prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, fullName: true }, }) : []; const nameById = new Map(users.map((u) => [u.id, u.fullName])); const denom = total || 1; return { total, newCount, unassigned, byAssignee: byAssigneeRaw .filter((r) => r.assigneeId) .map((r) => ({ userId: r.assigneeId!, fullName: nameById.get(r.assigneeId!) ?? '—', count: r._count._all, })) .sort((a, b) => b.count - a.count), callsLogged, withFollowUp, interested: interestedCount, converted, lost, conversionRate: Math.round((converted / denom) * 1000) / 10, }; } async createFromImportRow( organizationId: string, createdById: string | null, row: { title?: string; contactName?: string; mobile?: string; phone?: string; city?: string; region?: string; address?: string; address2?: string; homeAddress?: string; instagram?: string; birthDate?: string; registeredAt?: string; trade?: string; source?: string; notes?: string; }, defaultStageId: string, ): Promise<'ok' | 'duplicate' | 'error'> { const mobile = mobileKey(row.mobile); const title = String(row.title ?? row.contactName ?? '').trim(); if (!mobile || !title) return 'error'; const birthDate = parseBirthDate(row.birthDate); const existingLead = await this.prisma.lead.findFirst({ where: { organizationId, mobile }, select: { id: true }, }); if (existingLead) { if (birthDate) { await this.prisma.lead.updateMany({ where: { id: existingLead.id, birthDate: null }, data: { birthDate }, }); } return 'duplicate'; } const existingPerson = await this.prisma.person.findFirst({ where: { organizationId, OR: [{ mobile }, { phone: mobile }], }, select: { id: true }, }); if (existingPerson) { if (birthDate) { await this.prisma.person.updateMany({ where: { id: existingPerson.id, birthDate: null }, data: { birthDate }, }); } return 'duplicate'; } try { const registeredAt = row.registeredAt ? new Date(row.registeredAt) : null; const lead = await this.prisma.lead.create({ data: { organizationId, title, contactName: emptyToNull(row.contactName), mobile, phone: emptyToNull(row.phone), city: emptyToNull(row.city), region: emptyToNull(row.region), instagram: emptyToNull(row.instagram), birthDate, addresses: toJsonAddresses( collectAddresses(row.address, row.address2, row.homeAddress), ), trade: emptyToNull(row.trade), source: emptyToNull(row.source) ?? 'ایمپورت اکسل', notes: emptyToNull(row.notes), stageId: defaultStageId, createdById, ...(registeredAt && !Number.isNaN(registeredAt.getTime()) ? { createdAt: registeredAt } : {}), }, }); await this.prisma.leadEvent.create({ data: { organizationId, leadId: lead.id, type: 'import', title: 'ورود از فایل اکسل', actorId: createdById, }, }); return 'ok'; } catch { return 'error'; } } private async findAccessible(actor: Actor, id: string): Promise { const row = await this.prisma.lead.findFirst({ where: { id, organizationId: actor.organizationId, ...leadAccessWhere(actor), }, include: leadInclude, }); if (!row) throw new NotFoundException('لید پیدا نشد'); return row; } private async addEvent( actor: Actor, leadId: string, type: string, title: string, body?: string | null, meta?: Record | null, ) { await this.prisma.leadEvent.create({ data: { organizationId: actor.organizationId, leadId, type, title, body: body ?? null, meta: meta ? (meta as Prisma.InputJsonValue) : undefined, actorId: actor.userId, }, }); } private toDto(row: LeadRow): LeadDto { const daysInStage = Math.max( 0, Math.floor( (Date.now() - row.updatedAt.getTime()) / (24 * 60 * 60 * 1000), ), ); return { id: row.id, title: row.title, contactName: row.contactName, mobile: row.mobile, phone: row.phone, city: row.city, region: row.region, instagram: row.instagram, birthDate: row.birthDate?.toISOString() ?? null, addresses: asAddresses(row.addresses), trade: row.trade, source: row.source, notes: row.notes, estimatedValue: row.estimatedValue != null ? Number(row.estimatedValue) : null, stageId: row.stageId, stageName: row.stage.name, position: row.position, assigneeId: row.assigneeId, assigneeName: row.assignee?.fullName ?? null, lastContactAt: row.lastContactAt?.toISOString() ?? null, nextFollowUpAt: row.nextFollowUpAt?.toISOString() ?? null, convertedPersonId: row.convertedPersonId, convertedAt: row.convertedAt?.toISOString() ?? null, archivedAt: row.archivedAt?.toISOString() ?? null, daysInStage, hasLogo: Boolean(row.logoStorageKey), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } async saveLogo( actor: Actor, id: string, file: Express.Multer.File | undefined, ): Promise { if (!file?.buffer?.length) { throw new BadRequestException('فایل تصویر ارسال نشده'); } if (file.size > LOGO_MAX_BYTES) { throw new BadRequestException('حجم تصویر باید کمتر از ۲ مگابایت باشد'); } const mime = (file.mimetype || '').toLowerCase(); const ext = LOGO_EXT[mime]; if (!ext) { throw new BadRequestException('فقط تصویر JPG، PNG، WebP یا GIF مجاز است'); } const existing = await this.findAccessible(actor, id); const prevKey = existing.logoStorageKey; const storageKey = path .join(actor.organizationId, 'leads', id, `logo.${ext}`) .replace(/\\/g, '/'); await this.files.save(storageKey, file.buffer); if (prevKey && prevKey !== storageKey) { await this.files.remove(prevKey); } const row = await this.prisma.lead.update({ where: { id }, data: { logoStorageKey: storageKey }, include: leadInclude, }); await this.addEvent(actor, id, 'note', 'لوگو به‌روز شد', null); return this.toDto(row); } async removeLogo(actor: Actor, id: string): Promise { const existing = await this.findAccessible(actor, id); if (existing.logoStorageKey) { await this.files.remove(existing.logoStorageKey); } const row = await this.prisma.lead.update({ where: { id }, data: { logoStorageKey: null }, include: leadInclude, }); return this.toDto(row); } async sendSms(actor: Actor, id: string, text: string) { const body = text.trim(); if (!body || body.length > 500) { throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.'); } const lead = await this.findAccessible(actor, id); const mobile = lead.mobile || lead.phone; if (!mobile) throw new BadRequestException('برای این لید شماره موبایل ثبت نشده است.'); const name = (lead.contactName || lead.title).trim(); const message = renderSmsText(body, { name, phone: mobile, title: lead.title, company: lead.contactName ? lead.title : '', }); if (!message) throw new BadRequestException('متن پیامک خالی است.'); const result = await this.sms.sendSmsDetailed(mobile, message); if (!result.ok) { throw new ServiceUnavailableException( result.reason === 'SMS not configured' ? 'ارسال پیامک روی سرور فعال نیست.' : 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.', ); } await this.addEvent(actor, id, 'note', 'پیامک ارسال شد', message); return { ok: true }; } async openLogo(actor: Actor, id: string) { const existing = await this.findAccessible(actor, id); if (!existing.logoStorageKey) { throw new NotFoundException('لوگو تنظیم نشده'); } const storageKey = existing.logoStorageKey; const ext = path.extname(storageKey).slice(1).toLowerCase(); const mime = Object.entries(LOGO_EXT).find(([, value]) => value === ext)?.[0] ?? 'image/jpeg'; return { stream: this.files.openReadStream(storageKey), mimeType: mime, }; } }