fix(api): guard unmoved deals before pipeline merge delete

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-12 21:32:21 +03:30
co-authored by Cursor
parent fd6e79c15d
commit 9bed6cac03
3 changed files with 92 additions and 59 deletions
+17
View File
@@ -0,0 +1,17 @@
# Task 5 report
Status: DONE
- shared build: OK
- pipeline-merge.test.ts: 5/5 pass
- api typecheck: OK
- vue-tsc: see VUE_EXIT in controller log
- graphify: attempted (may crash)
No commit required for verification-only task.
## Follow-up fix: final review important findings
Status: DONE
- I-1: Added in-transaction unmoved-deal count guard before deleting the source pipeline.
- I-3: Added 30000ms timeout to the interactive merge transaction.
- M-1: Added `@IsIn(['retarget_all'])` validation to `MergePipelineDto.leadFormMode`.
- graphify update: OK
- `cd apps/api; npx tsx --test test/pipeline-merge.test.ts`: OK, 5/5 pass
- `npm run typecheck -w @mento/api`: OK
+2
View File
@@ -4,6 +4,7 @@ import {
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsObject,
@@ -362,5 +363,6 @@ export class MergePipelineDto {
@IsOptional()
@IsString()
@IsIn(['retarget_all'])
leadFormMode?: 'retarget_all';
}
+73 -59
View File
@@ -416,87 +416,101 @@ export class PipelinesService {
);
}
const targetStagesById = new Map(target.stages.map((stage) => [stage.id, stage]));
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);
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({
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: null,
closedAt,
},
});
movedDeals += result.count;
continue;
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 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 leadForms = await tx.leadForm.updateMany({
where: { organizationId, pipelineId: source.id },
data: { pipelineId: target.id },
});
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;
}
retargetedLeadForms = leadForms.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 },
});
}
if (source.isDefault) {
await tx.pipeline.updateMany({
where: { organizationId, isDefault: true },
data: { isDefault: false },
const remaining = await tx.deal.count({
where: { organizationId, pipelineId: source.id },
});
await tx.pipeline.update({
where: { id: target.id },
data: { isDefault: true },
});
}
if (remaining > 0) {
throw new BadRequestException(
'برخی معامله‌ها منتقل نشدند؛ ادغام لغو شد.',
);
}
await tx.pipeline.delete({ where: { id: source.id } });
});
await tx.pipeline.delete({ where: { id: source.id } });
},
{ timeout: 30000 },
);
return {
ok: true,