Owners can filter by assignee and follow-up status, with extra filters in a drawer, while mobile keeps a compact search-and-filter bar without the stage chip grid. Co-authored-by: Cursor <cursoragent@cursor.com>
453 lines
12 KiB
TypeScript
453 lines
12 KiB
TypeScript
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 { memoryStorage } from 'multer';
|
|
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 { brand } from '@mento/shared';
|
|
import {
|
|
AssignLeadsDto,
|
|
AutoAssignLeadsDto,
|
|
BulkLeadStageDto,
|
|
ConvertLeadDto,
|
|
CreateLeadDto,
|
|
CreateLeadStageDto,
|
|
ImportLeadsDto,
|
|
LeadCallLogDto,
|
|
LeadFollowUpDto,
|
|
LeadNoteDto,
|
|
LeadSmsDto,
|
|
MoveLeadDto,
|
|
ReorderLeadStagesDto,
|
|
UpdateLeadDto,
|
|
UpdateLeadStageDto,
|
|
} from './dto';
|
|
import { LeadStagesService } from './lead-stages.service';
|
|
import { LeadsImportService } from './leads-import.service';
|
|
import { LeadsService } from './leads.service';
|
|
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
|
|
|
@Controller('leads')
|
|
@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
|
|
@RequireOrgModule('leads')
|
|
@RequirePermission('leads', { reads: true })
|
|
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) {
|
|
if (e instanceof BadRequestException) throw 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/preview')
|
|
@UseGuards(RolesGuard)
|
|
@Roles('owner', 'admin')
|
|
@UseInterceptors(
|
|
FileInterceptor('file', {
|
|
storage: memoryStorage(),
|
|
limits: { fileSize: 15_000_000 },
|
|
}),
|
|
)
|
|
importPreview(@UploadedFile() file?: Express.Multer.File) {
|
|
if (!file?.buffer?.length) {
|
|
throw new BadRequestException('فایل ارسال نشده');
|
|
}
|
|
return this.imports.preview(file);
|
|
}
|
|
|
|
@Post('import/file')
|
|
@UseGuards(RolesGuard)
|
|
@Roles('owner', 'admin')
|
|
@UseInterceptors(
|
|
FileInterceptor('file', {
|
|
storage: memoryStorage(),
|
|
limits: { fileSize: 15_000_000 },
|
|
}),
|
|
)
|
|
async importFile(
|
|
@CurrentUser() user: Actor,
|
|
@UploadedFile() file?: Express.Multer.File,
|
|
@Body('mapping') mapping?: string,
|
|
) {
|
|
if (!file?.buffer?.length) {
|
|
throw new BadRequestException('فایل ارسال نشده');
|
|
}
|
|
const rows = await this.imports.rowsFromFile(file, mapping);
|
|
if (!rows.length) {
|
|
throw new BadRequestException('ردیفی برای ایمپورت نیست');
|
|
}
|
|
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('ids')
|
|
listIds(
|
|
@CurrentUser() user: Actor,
|
|
@Query('q') q?: string,
|
|
@Query('stageId') stageId?: string,
|
|
@Query('assigneeId') assigneeId?: string,
|
|
@Query('city') city?: string,
|
|
@Query('region') region?: string,
|
|
@Query('trade') trade?: string,
|
|
@Query('source') source?: string,
|
|
@Query('unassigned') unassigned?: string,
|
|
@Query('archived') archived?: string,
|
|
@Query('converted') converted?: string,
|
|
@Query('hasMobile') hasMobile?: string,
|
|
@Query('followUp') followUp?: string,
|
|
@Query('createdFrom') createdFrom?: string,
|
|
@Query('createdTo') createdTo?: string,
|
|
) {
|
|
return this.leads.listIds(user, {
|
|
q,
|
|
stageId,
|
|
assigneeId,
|
|
city,
|
|
region,
|
|
trade,
|
|
source,
|
|
unassigned: unassigned === '1' || unassigned === 'true',
|
|
archived: archived === '1' || archived === 'true',
|
|
converted: converted === '1' || converted === 'true',
|
|
hasMobile: hasMobile === '1' || hasMobile === 'true',
|
|
followUp,
|
|
createdFrom,
|
|
createdTo,
|
|
});
|
|
}
|
|
|
|
@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('region') region?: string,
|
|
@Query('trade') trade?: string,
|
|
@Query('source') source?: string,
|
|
@Query('unassigned') unassigned?: string,
|
|
@Query('archived') archived?: string,
|
|
@Query('converted') converted?: string,
|
|
@Query('sort') sort?: string,
|
|
@Query('hasMobile') hasMobile?: string,
|
|
@Query('followUp') followUp?: string,
|
|
@Query('createdFrom') createdFrom?: string,
|
|
@Query('createdTo') createdTo?: string,
|
|
) {
|
|
return this.leads.list(user, {
|
|
q,
|
|
page: page ? Number(page) : 1,
|
|
limit: limit ? Number(limit) : 20,
|
|
stageId,
|
|
assigneeId,
|
|
city,
|
|
region,
|
|
trade,
|
|
source,
|
|
unassigned: unassigned === '1' || unassigned === 'true',
|
|
archived: archived === '1' || archived === 'true',
|
|
converted: converted === '1' || converted === 'true',
|
|
sort,
|
|
hasMobile: hasMobile === '1' || hasMobile === 'true',
|
|
followUp,
|
|
createdFrom,
|
|
createdTo,
|
|
});
|
|
}
|
|
|
|
@Post()
|
|
create(@CurrentUser() user: Actor, @Body() dto: CreateLeadDto) {
|
|
return this.leads.create(user, dto);
|
|
}
|
|
|
|
@Post('assign')
|
|
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);
|
|
}
|
|
|
|
@Get(':id/logo')
|
|
async logo(@CurrentUser() user: Actor, @Param('id') id: string) {
|
|
const file = await this.leads.openLogo(user, id);
|
|
return new StreamableFile(file.stream, { type: file.mimeType });
|
|
}
|
|
|
|
@Post(':id/logo')
|
|
@UseInterceptors(
|
|
FileInterceptor('file', {
|
|
storage: memoryStorage(),
|
|
limits: { fileSize: 2 * 1024 * 1024 },
|
|
}),
|
|
)
|
|
uploadLogo(
|
|
@CurrentUser() user: Actor,
|
|
@Param('id') id: string,
|
|
@UploadedFile() file: Express.Multer.File,
|
|
) {
|
|
return this.leads.saveLogo(user, id, file);
|
|
}
|
|
|
|
@Delete(':id/logo')
|
|
removeLogo(@CurrentUser() user: Actor, @Param('id') id: string) {
|
|
return this.leads.removeLogo(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);
|
|
}
|
|
|
|
@Get(':id/sms-history')
|
|
smsHistory(
|
|
@CurrentUser() user: Actor,
|
|
@Param('id') id: string,
|
|
@Query('limit') limit?: string,
|
|
) {
|
|
return this.leads.listSmsHistory(user, id, limit ? Number(limit) : 50);
|
|
}
|
|
|
|
@Post(':id/sms')
|
|
sms(
|
|
@CurrentUser() user: Actor,
|
|
@Param('id') id: string,
|
|
@Body() dto: LeadSmsDto,
|
|
) {
|
|
return this.leads.sendSms(user, id, dto.text);
|
|
}
|
|
|
|
@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);
|
|
}
|
|
}
|