Files
CRM_Montzar/apps/api/src/leads/leads.service.ts
T
Mahdi 647503810d Add lead company logos and refine Leads stats cards.
Leads can upload/change/remove a logo shown in the list; stats use fixed stage labels with accurate non-archived counts.
2026-09-27 21:17:42 +03:30

1078 lines
31 KiB
TypeScript

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<string, string> = {
'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<PaginatedResponse<LeadDto>> {
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<LeadBoardColumnDto[]> {
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<string, LeadDto[]>();
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<LeadDto & { events: LeadEventDto[] }> {
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<LeadDuplicateCheckResult> {
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<LeadDto> {
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<string, unknown>,
): Promise<LeadDto> {
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<LeadDto> {
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<number> {
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<number> {
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<LeadDto> {
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<LeadDto> {
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<LeadDto> {
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<number> {
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<ConvertLeadResult> {
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<LeadStatsDto> {
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<LeadRow> {
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<string, unknown> | 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<LeadDto> {
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<LeadDto> {
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,
};
}
}