diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md new file mode 100644 index 0000000..180e3f6 --- /dev/null +++ b/.superpowers/sdd/task-5-report.md @@ -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 diff --git a/apps/api/src/pipelines/dto.ts b/apps/api/src/pipelines/dto.ts index 5b208cb..9ef327a 100644 --- a/apps/api/src/pipelines/dto.ts +++ b/apps/api/src/pipelines/dto.ts @@ -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'; } diff --git a/apps/api/src/pipelines/pipelines.service.ts b/apps/api/src/pipelines/pipelines.service.ts index 82aed65..572b4fc 100644 --- a/apps/api/src/pipelines/pipelines.service.ts +++ b/apps/api/src/pipelines/pipelines.service.ts @@ -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,