Add standalone Leads module for prospect outreach before customers.
Import large Excel lists with duplicate checks, assign and kanban follow-up, convert to Person when ready; also fix OTP mobile mask order and SMS brand name to منتکت. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -42,6 +42,7 @@ import { TeamsModule } from './teams/teams.module';
|
||||
import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module';
|
||||
import { VoipModule } from './integrations/voip/voip.module';
|
||||
import { ReservationsModule } from './reservations/reservations.module';
|
||||
import { LeadsModule } from './leads/leads.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -61,6 +62,7 @@ import { ReservationsModule } from './reservations/reservations.module';
|
||||
OrgRolesModule,
|
||||
OrganizationsModule,
|
||||
ContactsModule,
|
||||
LeadsModule,
|
||||
PipelinesModule,
|
||||
ActivitiesModule,
|
||||
NotesModule,
|
||||
|
||||
@@ -77,7 +77,8 @@ function maskEmail(email: string): string {
|
||||
|
||||
function maskMobile(mobile: string): string {
|
||||
if (mobile.length < 4) return '****';
|
||||
return `${mobile.slice(0, 4)}***${mobile.slice(-2)}`;
|
||||
// نمایش RTL: دو رقم آخر سپس *** سپس پیششماره (مثلاً 69***0913)
|
||||
return `${mobile.slice(-2)}***${mobile.slice(0, 4)}`;
|
||||
}
|
||||
|
||||
function generateOtp(): string {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
/** پنل ترِز / رایگان — همان الگوی montime */
|
||||
const SMS_TREZ_DEFAULT_BASE_URL = 'http://smspanel.Trez.ir';
|
||||
@@ -44,8 +43,8 @@ export class SmsService {
|
||||
|
||||
async sendOtpSms(mobile: string, code: string): Promise<boolean> {
|
||||
const template =
|
||||
this.config.get<string>('SMS_OTP_TEMPLATE') ??
|
||||
`کد ورود پرتال ${brand.nameFa}: {code}`;
|
||||
this.config.get<string>('SMS_OTP_TEMPLATE')?.trim() ||
|
||||
`کد ورود منتکت: {code}`;
|
||||
const text = template.replace('{code}', code);
|
||||
const result = await this.sendSmsDetailed(mobile, text);
|
||||
return result.ok;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateLeadDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
mobile!: string;
|
||||
|
||||
@IsOptional() @IsString() contactName?: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsString() city?: string;
|
||||
@IsOptional() @IsString() region?: string;
|
||||
@IsOptional() @IsString() trade?: string;
|
||||
@IsOptional() @IsString() source?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
@IsOptional() @IsNumber() estimatedValue?: number;
|
||||
@IsOptional() @IsString() stageId?: string;
|
||||
@IsOptional() @IsString() assigneeId?: string | null;
|
||||
@IsOptional() @IsString() nextFollowUpAt?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateLeadDto {
|
||||
@IsOptional() @IsString() @MinLength(1) title?: string;
|
||||
@IsOptional() @IsString() @MinLength(5) mobile?: string;
|
||||
@IsOptional() @IsString() contactName?: string | null;
|
||||
@IsOptional() @IsString() phone?: string | null;
|
||||
@IsOptional() @IsString() city?: string | null;
|
||||
@IsOptional() @IsString() region?: string | null;
|
||||
@IsOptional() @IsString() trade?: string | null;
|
||||
@IsOptional() @IsString() source?: string | null;
|
||||
@IsOptional() @IsString() notes?: string | null;
|
||||
@IsOptional() @IsNumber() estimatedValue?: number | null;
|
||||
@IsOptional() @IsString() stageId?: string;
|
||||
@IsOptional() @IsString() assigneeId?: string | null;
|
||||
@IsOptional() @IsString() nextFollowUpAt?: string | null;
|
||||
@IsOptional() @IsString() lastContactAt?: string | null;
|
||||
}
|
||||
|
||||
export class MoveLeadDto {
|
||||
@IsString() stageId!: string;
|
||||
@IsOptional() @IsNumber() position?: number;
|
||||
}
|
||||
|
||||
export class AssignLeadsDto {
|
||||
@IsArray() @IsString({ each: true }) leadIds!: string[];
|
||||
@IsString() assigneeId!: string;
|
||||
}
|
||||
|
||||
export class BulkLeadStageDto {
|
||||
@IsArray() @IsString({ each: true }) leadIds!: string[];
|
||||
@IsString() stageId!: string;
|
||||
}
|
||||
|
||||
export class AutoAssignLeadsDto {
|
||||
@IsOptional() @IsIn(['round_robin', 'equal_split']) rule?: string;
|
||||
@IsOptional() @IsArray() @IsString({ each: true }) leadIds?: string[];
|
||||
@IsOptional() @IsArray() @IsString({ each: true }) assigneeIds?: string[];
|
||||
@IsOptional() @IsBoolean() onlyUnassigned?: boolean;
|
||||
}
|
||||
|
||||
export class LeadCallLogDto {
|
||||
@IsOptional() @IsString() outcome?: string;
|
||||
@IsOptional() @IsString() body?: string;
|
||||
@IsOptional() @IsString() nextFollowUpAt?: string | null;
|
||||
@IsOptional() @IsString() stageId?: string;
|
||||
}
|
||||
|
||||
export class LeadNoteDto {
|
||||
@IsString() @MinLength(1) body!: string;
|
||||
}
|
||||
|
||||
export class LeadFollowUpDto {
|
||||
@IsString() nextFollowUpAt!: string;
|
||||
@IsOptional() @IsString() body?: string;
|
||||
}
|
||||
|
||||
export class ConvertLeadDto {
|
||||
@IsOptional() @IsString() firstName?: string;
|
||||
@IsOptional() @IsString() lastName?: string;
|
||||
}
|
||||
|
||||
export class CreateLeadStageDto {
|
||||
@IsString() @MinLength(1) name!: string;
|
||||
@IsOptional() @IsString() color?: string;
|
||||
@IsOptional() @IsBoolean() showOnBoard?: boolean;
|
||||
@IsOptional() @IsBoolean() isConverted?: boolean;
|
||||
@IsOptional() @IsBoolean() isLost?: boolean;
|
||||
@IsOptional() @IsBoolean() isUnqualified?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateLeadStageDto {
|
||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||
@IsOptional() @IsString() color?: string;
|
||||
@IsOptional() @IsBoolean() showOnBoard?: boolean;
|
||||
@IsOptional() @IsBoolean() isConverted?: boolean;
|
||||
@IsOptional() @IsBoolean() isLost?: boolean;
|
||||
@IsOptional() @IsBoolean() isUnqualified?: boolean;
|
||||
@IsOptional() @IsNumber() position?: number;
|
||||
}
|
||||
|
||||
export class ReorderLeadStagesDto {
|
||||
@IsArray() @IsString({ each: true }) stageIds!: string[];
|
||||
}
|
||||
|
||||
export class ImportLeadsDto {
|
||||
@IsArray() rows!: Array<Record<string, unknown>>;
|
||||
@IsOptional() @IsString() fileName?: string;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { LeadStageDto } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const DEFAULT_STAGES: Array<{
|
||||
name: string;
|
||||
color: string;
|
||||
isConverted?: boolean;
|
||||
isLost?: boolean;
|
||||
isUnqualified?: boolean;
|
||||
}> = [
|
||||
{ name: 'جدید', color: 'sky' },
|
||||
{ name: 'تماس اولیه', color: 'teal' },
|
||||
{ name: 'عدم پاسخ', color: 'amber' },
|
||||
{ name: 'پیگیری مجدد', color: 'amber' },
|
||||
{ name: 'تماس موفق', color: 'mint' },
|
||||
{ name: 'علاقهمند', color: 'violet' },
|
||||
{ name: 'واجد شرایط', color: 'violet' },
|
||||
{ name: 'مذاکره', color: 'peach' },
|
||||
{ name: 'آماده خرید', color: 'rose' },
|
||||
{ name: 'تبدیلشده', color: 'teal', isConverted: true },
|
||||
{ name: 'از دست رفته', color: 'rose', isLost: true },
|
||||
{ name: 'نامرتبط', color: 'amber', isUnqualified: true },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class LeadStagesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async ensureDefaults(organizationId: string): Promise<void> {
|
||||
const count = await this.prisma.leadStage.count({
|
||||
where: { organizationId },
|
||||
});
|
||||
if (count > 0) return;
|
||||
await this.prisma.leadStage.createMany({
|
||||
data: DEFAULT_STAGES.map((s, i) => ({
|
||||
organizationId,
|
||||
name: s.name,
|
||||
position: i,
|
||||
color: s.color,
|
||||
showOnBoard: true,
|
||||
isConverted: Boolean(s.isConverted),
|
||||
isLost: Boolean(s.isLost),
|
||||
isUnqualified: Boolean(s.isUnqualified),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async list(organizationId: string): Promise<LeadStageDto[]> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
const rows = await this.prisma.leadStage.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: { position: 'asc' },
|
||||
include: { _count: { select: { leads: true } } },
|
||||
});
|
||||
return rows.map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: {
|
||||
name: string;
|
||||
color?: string;
|
||||
showOnBoard?: boolean;
|
||||
isConverted?: boolean;
|
||||
isLost?: boolean;
|
||||
isUnqualified?: boolean;
|
||||
},
|
||||
): Promise<LeadStageDto> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
const max = await this.prisma.leadStage.aggregate({
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
});
|
||||
const row = await this.prisma.leadStage.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: dto.name.trim(),
|
||||
color: dto.color?.trim() || 'teal',
|
||||
showOnBoard: dto.showOnBoard !== false,
|
||||
isConverted: Boolean(dto.isConverted),
|
||||
isLost: Boolean(dto.isLost),
|
||||
isUnqualified: Boolean(dto.isUnqualified),
|
||||
position: (max._max.position ?? -1) + 1,
|
||||
},
|
||||
include: { _count: { select: { leads: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: {
|
||||
name?: string;
|
||||
color?: string;
|
||||
showOnBoard?: boolean;
|
||||
isConverted?: boolean;
|
||||
isLost?: boolean;
|
||||
isUnqualified?: boolean;
|
||||
position?: number;
|
||||
},
|
||||
): Promise<LeadStageDto> {
|
||||
await this.prisma.leadStage.updateMany({
|
||||
where: { id, organizationId },
|
||||
data: {
|
||||
...(dto.name != null ? { name: dto.name.trim() } : {}),
|
||||
...(dto.color != null ? { color: dto.color } : {}),
|
||||
...(dto.showOnBoard != null ? { showOnBoard: dto.showOnBoard } : {}),
|
||||
...(dto.isConverted != null ? { isConverted: dto.isConverted } : {}),
|
||||
...(dto.isLost != null ? { isLost: dto.isLost } : {}),
|
||||
...(dto.isUnqualified != null
|
||||
? { isUnqualified: dto.isUnqualified }
|
||||
: {}),
|
||||
...(dto.position != null ? { position: dto.position } : {}),
|
||||
},
|
||||
});
|
||||
const row = await this.prisma.leadStage.findFirstOrThrow({
|
||||
where: { id, organizationId },
|
||||
include: { _count: { select: { leads: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async reorder(organizationId: string, stageIds: string[]): Promise<LeadStageDto[]> {
|
||||
await this.prisma.$transaction(
|
||||
stageIds.map((id, position) =>
|
||||
this.prisma.leadStage.updateMany({
|
||||
where: { id, organizationId },
|
||||
data: { position },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return this.list(organizationId);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
const stage = await this.prisma.leadStage.findFirst({
|
||||
where: { id, organizationId },
|
||||
include: { _count: { select: { leads: true } } },
|
||||
});
|
||||
if (!stage) return;
|
||||
if (stage._count.leads > 0) {
|
||||
throw new Error('مرحلهای که لید دارد قابل حذف نیست');
|
||||
}
|
||||
await this.prisma.leadStage.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
color: string;
|
||||
showOnBoard: boolean;
|
||||
isConverted: boolean;
|
||||
isLost: boolean;
|
||||
isUnqualified: boolean;
|
||||
_count?: { leads: number };
|
||||
}): LeadStageDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
position: row.position,
|
||||
color: row.color,
|
||||
showOnBoard: row.showOnBoard,
|
||||
isConverted: row.isConverted,
|
||||
isLost: row.isLost,
|
||||
isUnqualified: row.isUnqualified,
|
||||
leadCount: row._count?.leads,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { LeadImportJobDto } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LeadStagesService } from './lead-stages.service';
|
||||
import { LeadsService } from './leads.service';
|
||||
|
||||
@Injectable()
|
||||
export class LeadsImportService {
|
||||
private readonly logger = new Logger(LeadsImportService.name);
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly stages: LeadStagesService,
|
||||
private readonly leads: LeadsService,
|
||||
) {}
|
||||
|
||||
async enqueue(
|
||||
organizationId: string,
|
||||
createdById: string,
|
||||
rows: Array<Record<string, unknown>>,
|
||||
fileName?: string,
|
||||
): Promise<LeadImportJobDto> {
|
||||
await this.stages.ensureDefaults(organizationId);
|
||||
const normalized = rows.map((r) => this.normalizeRow(r));
|
||||
const job = await this.prisma.leadImportJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
createdById,
|
||||
fileName: fileName ?? null,
|
||||
status: 'pending',
|
||||
totalCount: normalized.length,
|
||||
rows: normalized as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return this.toDto(job);
|
||||
}
|
||||
|
||||
async getJob(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
): Promise<LeadImportJobDto | null> {
|
||||
const job = await this.prisma.leadImportJob.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
return job ? this.toDto(job) : null;
|
||||
}
|
||||
|
||||
@Cron('*/1 * * * *')
|
||||
async processDue(): Promise<number> {
|
||||
if (this.running) return 0;
|
||||
this.running = true;
|
||||
try {
|
||||
const jobs = await this.prisma.leadImportJob.findMany({
|
||||
where: { status: 'pending' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 2,
|
||||
});
|
||||
for (const job of jobs) {
|
||||
await this.processJob(job.id);
|
||||
}
|
||||
return jobs.length;
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async processJob(id: string): Promise<void> {
|
||||
const claimed = await this.prisma.leadImportJob.updateMany({
|
||||
where: { id, status: 'pending' },
|
||||
data: { status: 'processing', startedAt: new Date() },
|
||||
});
|
||||
if (!claimed.count) return;
|
||||
|
||||
const job = await this.prisma.leadImportJob.findUnique({ where: { id } });
|
||||
if (!job) return;
|
||||
|
||||
const stages = await this.stages.list(job.organizationId);
|
||||
const defaultStageId = stages[0]?.id;
|
||||
if (!defaultStageId) {
|
||||
await this.prisma.leadImportJob.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
finishedAt: new Date(),
|
||||
errorSamples: ['مرحله پیشفرض لید وجود ندارد'],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = (job.rows as Array<Record<string, string>>) ?? [];
|
||||
const errorSamples: string[] = [];
|
||||
let success = 0;
|
||||
let duplicate = 0;
|
||||
let error = 0;
|
||||
let processed = 0;
|
||||
|
||||
const BATCH = 50;
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
const chunk = rows.slice(i, i + BATCH);
|
||||
for (const row of chunk) {
|
||||
const result = await this.leads.createFromImportRow(
|
||||
job.organizationId,
|
||||
job.createdById,
|
||||
row,
|
||||
defaultStageId,
|
||||
);
|
||||
processed += 1;
|
||||
if (result === 'ok') success += 1;
|
||||
else if (result === 'duplicate') duplicate += 1;
|
||||
else {
|
||||
error += 1;
|
||||
if (errorSamples.length < 20) {
|
||||
errorSamples.push(
|
||||
`ردیف ${processed}: ${row.title || row.mobile || 'نامعتبر'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.prisma.leadImportJob.update({
|
||||
where: { id },
|
||||
data: {
|
||||
processedCount: processed,
|
||||
successCount: success,
|
||||
duplicateCount: duplicate,
|
||||
errorCount: error,
|
||||
errorSamples,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.leadImportJob.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'done',
|
||||
processedCount: processed,
|
||||
successCount: success,
|
||||
duplicateCount: duplicate,
|
||||
errorCount: error,
|
||||
errorSamples,
|
||||
finishedAt: new Date(),
|
||||
rows: [],
|
||||
},
|
||||
});
|
||||
this.logger.log(
|
||||
`Lead import ${id}: ok=${success} dup=${duplicate} err=${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeRow(raw: Record<string, unknown>): Record<string, string> {
|
||||
const get = (...keys: string[]) => {
|
||||
for (const k of keys) {
|
||||
const direct = raw[k];
|
||||
if (direct != null && String(direct).trim()) return String(direct).trim();
|
||||
const found = Object.entries(raw).find(
|
||||
([key]) => key.trim().toLowerCase() === k.toLowerCase(),
|
||||
);
|
||||
if (found?.[1] != null && String(found[1]).trim()) {
|
||||
return String(found[1]).trim();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
return {
|
||||
title: get('title', 'نام', 'نام فروشگاه', 'فروشگاه', 'shop', 'name'),
|
||||
contactName: get('contactName', 'مالک', 'مسئول', 'نام مالک', 'owner'),
|
||||
mobile: get('mobile', 'موبایل', 'شماره', 'شماره تماس', 'phone', 'tel'),
|
||||
phone: get('phone', 'تلفن'),
|
||||
city: get('city', 'شهر'),
|
||||
region: get('region', 'منطقه', 'محله'),
|
||||
trade: get('trade', 'صنف', 'رسته'),
|
||||
source: get('source', 'منبع', 'منبع لید'),
|
||||
notes: get('notes', 'توضیحات', 'یادداشت'),
|
||||
};
|
||||
}
|
||||
|
||||
private toDto(job: {
|
||||
id: string;
|
||||
fileName: string | null;
|
||||
status: string;
|
||||
totalCount: number;
|
||||
processedCount: number;
|
||||
successCount: number;
|
||||
duplicateCount: number;
|
||||
errorCount: number;
|
||||
errorSamples: unknown;
|
||||
startedAt: Date | null;
|
||||
finishedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}): LeadImportJobDto {
|
||||
const samples = Array.isArray(job.errorSamples)
|
||||
? job.errorSamples.map((s) => String(s))
|
||||
: [];
|
||||
return {
|
||||
id: job.id,
|
||||
fileName: job.fileName,
|
||||
status: job.status,
|
||||
totalCount: job.totalCount,
|
||||
processedCount: job.processedCount,
|
||||
successCount: job.successCount,
|
||||
duplicateCount: job.duplicateCount,
|
||||
errorCount: job.errorCount,
|
||||
errorSamples: samples,
|
||||
startedAt: job.startedAt?.toISOString() ?? null,
|
||||
finishedAt: job.finishedAt?.toISOString() ?? null,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { cellStr } from '../common/excel';
|
||||
import { brand } from '@mento/shared';
|
||||
import {
|
||||
AssignLeadsDto,
|
||||
AutoAssignLeadsDto,
|
||||
BulkLeadStageDto,
|
||||
ConvertLeadDto,
|
||||
CreateLeadDto,
|
||||
CreateLeadStageDto,
|
||||
ImportLeadsDto,
|
||||
LeadCallLogDto,
|
||||
LeadFollowUpDto,
|
||||
LeadNoteDto,
|
||||
MoveLeadDto,
|
||||
ReorderLeadStagesDto,
|
||||
UpdateLeadDto,
|
||||
UpdateLeadStageDto,
|
||||
} from './dto';
|
||||
import { LeadStagesService } from './lead-stages.service';
|
||||
import { LeadsImportService } from './leads-import.service';
|
||||
import { LeadsService } from './leads.service';
|
||||
|
||||
@Controller('leads')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('leads')
|
||||
export class LeadsController {
|
||||
constructor(
|
||||
private readonly leads: LeadsService,
|
||||
private readonly stages: LeadStagesService,
|
||||
private readonly imports: LeadsImportService,
|
||||
) {}
|
||||
|
||||
@Get('stats')
|
||||
stats(@CurrentUser() user: Actor) {
|
||||
return this.leads.stats(user);
|
||||
}
|
||||
|
||||
@Get('board')
|
||||
board(@CurrentUser() user: Actor) {
|
||||
return this.leads.board(user);
|
||||
}
|
||||
|
||||
@Get('stages')
|
||||
listStages(@CurrentUser() user: Actor) {
|
||||
return this.stages.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Post('stages')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
createStage(@CurrentUser() user: Actor, @Body() dto: CreateLeadStageDto) {
|
||||
return this.stages.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch('stages/reorder')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
reorderStages(@CurrentUser() user: Actor, @Body() dto: ReorderLeadStagesDto) {
|
||||
return this.stages.reorder(user.organizationId, dto.stageIds);
|
||||
}
|
||||
|
||||
@Patch('stages/:id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
updateStage(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateLeadStageDto,
|
||||
) {
|
||||
return this.stages.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('stages/:id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
async removeStage(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
try {
|
||||
await this.stages.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
throw new BadRequestException(
|
||||
e instanceof Error ? e.message : 'حذف مرحله ناموفق بود',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('duplicate-check')
|
||||
checkDuplicate(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('mobile') mobile: string,
|
||||
) {
|
||||
return this.leads.checkDuplicate(user, mobile ?? '');
|
||||
}
|
||||
|
||||
@Get('imports/:id')
|
||||
getImport(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.imports.getJob(user.organizationId, id);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
importRows(@CurrentUser() user: Actor, @Body() dto: ImportLeadsDto) {
|
||||
if (!dto.rows?.length) {
|
||||
throw new BadRequestException('ردیفی برای ایمپورت نیست');
|
||||
}
|
||||
return this.imports.enqueue(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
dto.rows,
|
||||
dto.fileName,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('import/file')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async importFile(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
) {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل ارسال نشده');
|
||||
}
|
||||
const wb = new ExcelJS.Workbook();
|
||||
await wb.xlsx.load(Uint8Array.from(file.buffer) as unknown as ExcelJS.Buffer);
|
||||
const sheet = wb.worksheets[0];
|
||||
if (!sheet) throw new BadRequestException('برگه اکسل خالی است');
|
||||
const headerRow = sheet.getRow(1);
|
||||
const headers: string[] = [];
|
||||
headerRow.eachCell({ includeEmpty: true }, (cell, col) => {
|
||||
headers[col - 1] = cellStr(cell.value);
|
||||
});
|
||||
const rows: Array<Record<string, string>> = [];
|
||||
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
||||
if (rowNumber === 1) return;
|
||||
const obj: Record<string, string> = {};
|
||||
headers.forEach((h, i) => {
|
||||
if (!h) return;
|
||||
obj[h] = cellStr(row.getCell(i + 1).value);
|
||||
});
|
||||
if (Object.values(obj).some((v) => v.trim())) rows.push(obj);
|
||||
});
|
||||
return this.imports.enqueue(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
rows,
|
||||
file.originalname,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
async export(
|
||||
@CurrentUser() user: Actor,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const data = await this.leads.list(user, { page: 1, limit: 5000 });
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const sheet = wb.addWorksheet('leads');
|
||||
sheet.addRow([
|
||||
'نام',
|
||||
'موبایل',
|
||||
'مالک',
|
||||
'شهر',
|
||||
'منطقه',
|
||||
'صنف',
|
||||
'وضعیت',
|
||||
'کارشناس',
|
||||
'منبع',
|
||||
'پیگیری بعدی',
|
||||
'آخرین تماس',
|
||||
]);
|
||||
for (const l of data.items) {
|
||||
sheet.addRow([
|
||||
l.title,
|
||||
l.mobile,
|
||||
l.contactName,
|
||||
l.city,
|
||||
l.region,
|
||||
l.trade,
|
||||
l.stageName,
|
||||
l.assigneeName,
|
||||
l.source,
|
||||
l.nextFollowUpAt,
|
||||
l.lastContactAt,
|
||||
]);
|
||||
}
|
||||
const buffer = Buffer.from(await wb.xlsx.writeBuffer());
|
||||
res.set({
|
||||
'Content-Type':
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename="${brand.filePrefix}-leads.xlsx"`,
|
||||
});
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('q') q?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('stageId') stageId?: string,
|
||||
@Query('assigneeId') assigneeId?: string,
|
||||
@Query('city') city?: string,
|
||||
@Query('trade') trade?: string,
|
||||
@Query('source') source?: string,
|
||||
@Query('unassigned') unassigned?: string,
|
||||
@Query('archived') archived?: string,
|
||||
@Query('sort') sort?: string,
|
||||
) {
|
||||
return this.leads.list(user, {
|
||||
q,
|
||||
page: page ? Number(page) : 1,
|
||||
limit: limit ? Number(limit) : 20,
|
||||
stageId,
|
||||
assigneeId,
|
||||
city,
|
||||
trade,
|
||||
source,
|
||||
unassigned: unassigned === '1' || unassigned === 'true',
|
||||
archived: archived === '1' || archived === 'true',
|
||||
sort,
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: Actor, @Body() dto: CreateLeadDto) {
|
||||
return this.leads.create(user, dto);
|
||||
}
|
||||
|
||||
@Post('assign')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
assign(@CurrentUser() user: Actor, @Body() dto: AssignLeadsDto) {
|
||||
return this.leads
|
||||
.assign(user, dto.leadIds, dto.assigneeId)
|
||||
.then((count) => ({ count }));
|
||||
}
|
||||
|
||||
@Post('bulk-stage')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
bulkStage(@CurrentUser() user: Actor, @Body() dto: BulkLeadStageDto) {
|
||||
return this.leads
|
||||
.bulkStage(user, dto.leadIds, dto.stageId)
|
||||
.then((count) => ({ count }));
|
||||
}
|
||||
|
||||
@Post('auto-assign')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
autoAssign(@CurrentUser() user: Actor, @Body() dto: AutoAssignLeadsDto) {
|
||||
return this.leads.autoAssign(user, dto);
|
||||
}
|
||||
|
||||
@Post('archive')
|
||||
archive(@CurrentUser() user: Actor, @Body() body: { leadIds: string[] }) {
|
||||
return this.leads
|
||||
.archive(user, body.leadIds ?? [])
|
||||
.then((count) => ({ count }));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.leads.get(user, id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateLeadDto,
|
||||
) {
|
||||
return this.leads.update(user, id, dto as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
@Patch(':id/move')
|
||||
move(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: MoveLeadDto,
|
||||
) {
|
||||
return this.leads.move(user, id, dto.stageId, dto.position);
|
||||
}
|
||||
|
||||
@Post(':id/call')
|
||||
logCall(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: LeadCallLogDto,
|
||||
) {
|
||||
return this.leads.logCall(user, id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/notes')
|
||||
addNote(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: LeadNoteDto,
|
||||
) {
|
||||
return this.leads.addNote(user, id, dto.body);
|
||||
}
|
||||
|
||||
@Post(':id/follow-up')
|
||||
followUp(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: LeadFollowUpDto,
|
||||
) {
|
||||
return this.leads.setFollowUp(user, id, dto.nextFollowUpAt, dto.body);
|
||||
}
|
||||
|
||||
@Post(':id/convert')
|
||||
convert(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ConvertLeadDto,
|
||||
) {
|
||||
return this.leads.convert(user, id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { LeadStagesService } from './lead-stages.service';
|
||||
import { LeadsController } from './leads.controller';
|
||||
import { LeadsImportService } from './leads-import.service';
|
||||
import { LeadsService } from './leads.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BillingModule],
|
||||
controllers: [LeadsController],
|
||||
providers: [LeadsService, LeadStagesService, LeadsImportService],
|
||||
exports: [LeadsService, LeadStagesService],
|
||||
})
|
||||
export class LeadsModule {}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user