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, IsArray,
IsBoolean, IsBoolean,
IsDateString, IsDateString,
IsIn,
IsInt, IsInt,
IsNumber, IsNumber,
IsObject, IsObject,
@@ -362,5 +363,6 @@ export class MergePipelineDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsIn(['retarget_all'])
leadFormMode?: '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 movedDeals = 0;
let retargetedLeadForms = 0; let retargetedLeadForms = 0;
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(
const closedAt = new Date(); async (tx) => {
for (const sourceStage of source.stages) { const closedAt = new Date();
const targetStageId = dto.stageMap[sourceStage.id]; for (const sourceStage of source.stages) {
const targetStage = targetStagesById.get(targetStageId); const targetStageId = dto.stageMap[sourceStage.id];
if (!targetStage) { const targetStage = targetStagesById.get(targetStageId);
throw new BadRequestException('نگاشت ستون‌ها نامعتبر است'); if (!targetStage) {
} throw new BadRequestException('نگاشت ستون‌ها نامعتبر است');
const status = dealStatusForStage(targetStage); }
const status = dealStatusForStage(targetStage);
if (status === 'open') { if (status === 'open') {
const result = await tx.deal.updateMany({ 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: { where: {
organizationId, organizationId,
pipelineId: source.id, pipelineId: source.id,
stageId: sourceStage.id, stageId: sourceStage.id,
status: 'open',
}, },
data: { data: {
pipelineId: target.id, pipelineId: target.id,
stageId: targetStage.id, stageId: targetStage.id,
status, status,
closedAt: null, closedAt,
}, },
}); });
movedDeals += result.count; const alreadyClosed = await tx.deal.updateMany({
continue; 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({ const leadForms = await tx.leadForm.updateMany({
where: { where: { organizationId, pipelineId: source.id },
organizationId, data: { pipelineId: target.id },
pipelineId: source.id,
stageId: sourceStage.id,
status: 'open',
},
data: {
pipelineId: target.id,
stageId: targetStage.id,
status,
closedAt,
},
}); });
const alreadyClosed = await tx.deal.updateMany({ retargetedLeadForms = leadForms.count;
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({ if (source.isDefault) {
where: { organizationId, pipelineId: source.id }, await tx.pipeline.updateMany({
data: { pipelineId: target.id }, where: { organizationId, isDefault: true },
}); data: { isDefault: false },
retargetedLeadForms = leadForms.count; });
await tx.pipeline.update({
where: { id: target.id },
data: { isDefault: true },
});
}
if (source.isDefault) { const remaining = await tx.deal.count({
await tx.pipeline.updateMany({ where: { organizationId, pipelineId: source.id },
where: { organizationId, isDefault: true },
data: { isDefault: false },
}); });
await tx.pipeline.update({ if (remaining > 0) {
where: { id: target.id }, throw new BadRequestException(
data: { isDefault: true }, 'برخی معامله‌ها منتقل نشدند؛ ادغام لغو شد.',
}); );
} }
await tx.pipeline.delete({ where: { id: source.id } }); await tx.pipeline.delete({ where: { id: source.id } });
}); },
{ timeout: 30000 },
);
return { return {
ok: true, ok: true,