feat(api): pipeline merge preview and merge endpoints

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-12 20:23:56 +03:30
co-authored by Cursor
parent fd8966e13f
commit d00f362376
3 changed files with 211 additions and 0 deletions
+13
View File
@@ -351,3 +351,16 @@ export class MoveDealDto {
@Min(0)
position?: number;
}
export class MergePipelineDto {
@IsString()
@MinLength(1)
targetPipelineId!: string;
@IsObject()
stageMap!: Record<string, string>;
@IsOptional()
@IsString()
leadFormMode?: 'retarget_all';
}
@@ -17,6 +17,7 @@ import type { Actor } from '../common/record-scope';
import {
CreatePipelineDto,
CreatePipelineStageDto,
MergePipelineDto,
ReorderPipelineStagesDto,
UpdatePipelineStageDto,
} from './dto';
@@ -49,6 +50,25 @@ export class PipelinesController {
return this.pipelinesService.create(user.organizationId, dto.name);
}
@Get(':pipelineId/merge-preview')
@Roles('owner', 'admin')
mergePreview(
@CurrentUser() user: { organizationId: string },
@Param('pipelineId') pipelineId: string,
) {
return this.pipelinesService.mergePreview(user.organizationId, pipelineId);
}
@Post(':pipelineId/merge')
@Roles('owner', 'admin')
merge(
@CurrentUser() user: { organizationId: string },
@Param('pipelineId') pipelineId: string,
@Body() dto: MergePipelineDto,
) {
return this.pipelinesService.merge(user.organizationId, pipelineId, dto);
}
@Post(':pipelineId/stages')
@Roles('owner', 'admin')
createStage(
+178
View File
@@ -5,8 +5,10 @@ import {
} from '@nestjs/common';
import type {
DealDto,
MergePipelineResultDto,
PipelineBoardDto,
PipelineDto,
PipelineMergePreviewDto,
PipelineStageDto,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
@@ -14,6 +16,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { asCustomFields } from '../common/custom-fields';
import { type Actor, dealAccessWhere } from '../common/record-scope';
import { decimalToBase, loadOrgRates } from '../common/currency';
import { assertStageMap, dealStatusForStage } from './pipeline-merge.util';
export const DEFAULT_STAGES = [
{ name: 'سرنخ', isWon: false, isLost: false },
@@ -329,6 +332,181 @@ export class PipelinesService {
};
}
async mergePreview(
organizationId: string,
sourceId: string,
): Promise<PipelineMergePreviewDto> {
const source = await this.getOwnedPipeline(organizationId, sourceId);
const dealCounts = await this.prisma.deal.groupBy({
by: ['stageId'],
where: { organizationId, pipelineId: source.id },
_count: { _all: true },
});
const countByStage = new Map(
dealCounts.map((row) => [row.stageId, row._count._all]),
);
const leadForms = await this.prisma.leadForm.findMany({
where: { organizationId, pipelineId: source.id },
select: { id: true, name: true, isActive: true },
orderBy: { updatedAt: 'desc' },
});
const targets = await this.prisma.pipeline.findMany({
where: { organizationId, id: { not: source.id } },
include: { stages: { orderBy: { position: 'asc' } } },
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
});
const stages = source.stages.map((stage) => ({
id: stage.id,
name: stage.name,
isWon: stage.isWon,
isLost: stage.isLost,
dealCount: countByStage.get(stage.id) ?? 0,
}));
return {
source: {
id: source.id,
name: source.name,
isDefault: source.isDefault,
},
stages,
dealCount: stages.reduce((sum, stage) => sum + stage.dealCount, 0),
leadForms,
targets: targets.map((target) => ({
id: target.id,
name: target.name,
isDefault: target.isDefault,
stages: target.stages.map((stage) => ({
id: stage.id,
name: stage.name,
isWon: stage.isWon,
isLost: stage.isLost,
})),
})),
};
}
async merge(
organizationId: string,
sourceId: string,
dto: {
targetPipelineId: string;
stageMap: Record<string, string>;
leadFormMode?: 'retarget_all';
},
): Promise<MergePipelineResultDto> {
if (sourceId === dto.targetPipelineId) {
throw new BadRequestException('مسیر مقصد باید با مسیر مبدأ متفاوت باشد');
}
if ((dto.leadFormMode ?? 'retarget_all') !== 'retarget_all') {
throw new BadRequestException('روش انتقال فرم‌های جذب نامعتبر است');
}
const [source, target] = await Promise.all([
this.getOwnedPipeline(organizationId, sourceId),
this.getOwnedPipeline(organizationId, dto.targetPipelineId),
]);
try {
assertStageMap(source.stages, target.stages, dto.stageMap);
} catch (error) {
throw new BadRequestException(
error instanceof Error ? error.message : 'نگاشت ستون‌ها نامعتبر است',
);
}
const targetStagesById = new Map(target.stages.map((stage) => [stage.id, stage]));
let movedDeals = 0;
let retargetedLeadForms = 0;
await this.prisma.$transaction(async (tx) => {
const closedAt = new Date();
for (const sourceStage of source.stages) {
const targetStageId = dto.stageMap[sourceStage.id];
const targetStage = targetStagesById.get(targetStageId);
if (!targetStage) {
throw new BadRequestException('نگاشت ستون‌ها نامعتبر است');
}
const status = dealStatusForStage(targetStage);
if (status === 'open') {
const result = await tx.deal.updateMany({
where: {
organizationId,
pipelineId: source.id,
stageId: sourceStage.id,
},
data: {
pipelineId: target.id,
stageId: targetStage.id,
status,
closedAt: null,
},
});
movedDeals += result.count;
continue;
}
const opened = await tx.deal.updateMany({
where: {
organizationId,
pipelineId: source.id,
stageId: sourceStage.id,
status: 'open',
},
data: {
pipelineId: target.id,
stageId: targetStage.id,
status,
closedAt,
},
});
const alreadyClosed = await tx.deal.updateMany({
where: {
organizationId,
pipelineId: source.id,
stageId: sourceStage.id,
status: { not: 'open' },
},
data: {
pipelineId: target.id,
stageId: targetStage.id,
status,
},
});
movedDeals += opened.count + alreadyClosed.count;
}
const leadForms = await tx.leadForm.updateMany({
where: { organizationId, pipelineId: source.id },
data: { pipelineId: target.id },
});
retargetedLeadForms = leadForms.count;
if (source.isDefault) {
await tx.pipeline.updateMany({
where: { organizationId, isDefault: true },
data: { isDefault: false },
});
await tx.pipeline.update({
where: { id: target.id },
data: { isDefault: true },
});
}
await tx.pipeline.delete({ where: { id: source.id } });
});
return {
ok: true,
targetPipelineId: target.id,
movedDeals,
retargetedLeadForms,
deletedPipelineId: source.id,
};
}
toDealDto(row: DealRow): DealDto {
return {
id: row.id,