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'; @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/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() 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); } @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); } @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/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); } }