import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import * as path from 'node:path'; import type { ConvertLeadResult, LeadBoardColumnDto, LeadDto, LeadDuplicateCheckResult, LeadEventDto, LeadStatsDto, PaginatedResponse, } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import { type Actor, elevated, } from '../common/record-scope'; import { emptyToNull, normalizePhone } from '../contacts/normalize'; import { LocalFileStorage } from '../storage/local-file.storage'; import { LeadStagesService } from './lead-stages.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(actor: Actor): Prisma.LeadWhereInput { 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, ) {} async list( actor: Actor, opts: { q?: string; page?: number; limit?: number; stageId?: string; assigneeId?: string; city?: string; trade?: string; source?: string; unassigned?: boolean; archived?: boolean; sort?: string; }, ): 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 q = opts.q?.trim(); const where: Prisma.LeadWhereInput = { 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.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}), ...(opts.source ? { source: { contains: opts.source, mode: 'insensitive' } } : {}), ...(q ? { OR: [ { title: { contains: q, mode: 'insensitive' } }, { contactName: { contains: q, mode: 'insensitive' } }, { mobile: { contains: q } }, { city: { contains: q, mode: 'insensitive' } }, { trade: { contains: q, mode: 'insensitive' } }, ], } : {}), }; 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 board(actor: Actor): Promise { const stages = await this.stages.list(actor.organizationId); const boardStages = stages.filter((s) => s.showOnBoard); const leads = await this.prisma.lead.findMany({ where: { organizationId: actor.organizationId, ...leadAccessWhere(actor), archivedAt: null, stageId: { in: boardStages.map((s) => s.id) }, }, include: leadInclude, orderBy: [{ stageId: 'asc' }, { position: 'asc' }, { updatedAt: 'desc' }], take: 500, }); const byStage = new Map(); for (const s of boardStages) byStage.set(s.id, []); for (const row of leads) { const list = byStage.get(row.stageId); if (list) list.push(this.toDto(row)); } return boardStages.map((stage) => ({ stage, leads: byStage.get(stage.id) ?? [], })); } 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; trade?: string | null; source?: string | null; notes?: string | null; estimatedValue?: number | null; stageId?: string; assigneeId?: string | null; nextFollowUpAt?: 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 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), trade: emptyToNull(dto.trade), source: emptyToNull(dto.source), notes: emptyToNull(dto.notes), estimatedValue: dto.estimatedValue != null ? new Prisma.Decimal(dto.estimatedValue) : null, 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 }, ); } 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.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}» تغییر کرد`, ); } return this.toDto(row); } async move( actor: Actor, id: string, stageId: string, position?: number, ): Promise { 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}» منتقل شد`, ); 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 { 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, }); 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); 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, }); 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, 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, }, }); createdPerson = true; } 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); 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; 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 existingLead = await this.prisma.lead.findFirst({ where: { organizationId, mobile }, select: { id: true }, }); if (existingLead) return 'duplicate'; const existingPerson = await this.prisma.person.findFirst({ where: { organizationId, OR: [{ mobile }, { phone: mobile }], }, select: { id: true }, }); if (existingPerson) return 'duplicate'; try { 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), trade: emptyToNull(row.trade), source: emptyToNull(row.source) ?? 'ایمپورت اکسل', notes: emptyToNull(row.notes), stageId: defaultStageId, createdById, }, }); 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, 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 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, }; } }