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:
2026-09-27 17:05:11 +03:30
co-authored by Cursor
parent 22980e5152
commit c079295ff4
24 changed files with 4357 additions and 9 deletions
+2
View File
@@ -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,
+2 -1
View File
@@ -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 {
+2 -3
View File
@@ -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;
+119
View File
@@ -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;
}
+172
View File
@@ -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,
};
}
}
+212
View File
@@ -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(),
};
}
}
+351
View File
@@ -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);
}
}
+15
View File
@@ -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