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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user