From fd8966e13f6881a8631f7a4f158d54d28b3f314a Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 12 Oct 2026 20:15:11 +0330 Subject: [PATCH] feat(pipelines): merge stage-map util and shared DTOs Co-authored-by: Cursor --- apps/api/package.json | 2 +- apps/api/src/pipelines/pipeline-merge.util.ts | 41 ++++++++++++ apps/api/test/pipeline-merge.test.ts | 62 +++++++++++++++++++ packages/shared/src/index.ts | 44 +++++++++++++ 4 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/pipelines/pipeline-merge.util.ts create mode 100644 apps/api/test/pipeline-merge.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index 891a384..3bbba1f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,7 +12,7 @@ "prisma:seed": "tsx prisma/seed.ts", "lint": "echo \"api lint placeholder\"", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json", - "test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts" + "test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts" }, "dependencies": { "@mento/shared": "*", diff --git a/apps/api/src/pipelines/pipeline-merge.util.ts b/apps/api/src/pipelines/pipeline-merge.util.ts new file mode 100644 index 0000000..17852cc --- /dev/null +++ b/apps/api/src/pipelines/pipeline-merge.util.ts @@ -0,0 +1,41 @@ +export type MergeStageLike = { + id: string; + name: string; + isWon: boolean; + isLost: boolean; +}; + +export function dealStatusForStage( + stage: Pick, +): 'open' | 'won' | 'lost' { + if (stage.isWon) return 'won'; + if (stage.isLost) return 'lost'; + return 'open'; +} + +export function assertStageMap( + sourceStages: MergeStageLike[], + targetStages: MergeStageLike[], + stageMap: Record, +): void { + const targetById = new Map(targetStages.map((s) => [s.id, s])); + for (const src of sourceStages) { + const tid = stageMap[src.id]; + if (!tid) { + throw new Error(`همهٔ ستون‌های مبدأ باید نگاشت شوند (ستون «${src.name}»).`); + } + const tgt = targetById.get(tid); + if (!tgt) { + throw new Error(`ستون مقصد برای «${src.name}» معتبر نیست.`); + } + if (src.isWon && !tgt.isWon) { + throw new Error(`ستون برندهٔ «${src.name}» فقط به ستون برندهٔ مقصد نگاشت می‌شود.`); + } + if (src.isLost && !tgt.isLost) { + throw new Error(`ستون از دست‌رفتهٔ «${src.name}» فقط به ستون از دست‌رفتهٔ مقصد نگاشت می‌شود.`); + } + if (!src.isWon && !src.isLost && (tgt.isWon || tgt.isLost)) { + throw new Error(`ستون باز «${src.name}» را به ستون برد/باخت مقصد نگاشت نکنید.`); + } + } +} diff --git a/apps/api/test/pipeline-merge.test.ts b/apps/api/test/pipeline-merge.test.ts new file mode 100644 index 0000000..8d72b13 --- /dev/null +++ b/apps/api/test/pipeline-merge.test.ts @@ -0,0 +1,62 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + assertStageMap, + dealStatusForStage, +} from '../src/pipelines/pipeline-merge.util'; + +type S = { id: string; name: string; isWon: boolean; isLost: boolean }; + +const openA: S = { id: 'a1', name: 'سرنخ', isWon: false, isLost: false }; +const wonA: S = { id: 'aW', name: 'برنده', isWon: true, isLost: false }; +const lostA: S = { id: 'aL', name: 'از دست‌رفته', isWon: false, isLost: true }; +const openB: S = { id: 'b1', name: 'تماس', isWon: false, isLost: false }; +const wonB: S = { id: 'bW', name: 'برنده', isWon: true, isLost: false }; +const lostB: S = { id: 'bL', name: 'باخت', isWon: false, isLost: true }; + +describe('pipeline merge stage map', () => { + it('rejects incomplete map', () => { + assert.throws( + () => assertStageMap([openA, wonA], [openB, wonB], { a1: 'b1' }), + /همهٔ ستون/, + ); + }); + + it('rejects won → open', () => { + assert.throws( + () => + assertStageMap([openA, wonA], [openB, wonB], { + a1: 'b1', + aW: 'b1', + }), + /برنده/, + ); + }); + + it('rejects lost → open', () => { + assert.throws( + () => + assertStageMap([openA, lostA], [openB, lostB], { + a1: 'b1', + aL: 'b1', + }), + /از دست/, + ); + }); + + it('accepts valid mapping', () => { + assert.doesNotThrow(() => + assertStageMap([openA, wonA, lostA], [openB, wonB, lostB], { + a1: 'b1', + aW: 'bW', + aL: 'bL', + }), + ); + }); + + it('dealStatusForStage', () => { + assert.equal(dealStatusForStage(wonB), 'won'); + assert.equal(dealStatusForStage(lostB), 'lost'); + assert.equal(dealStatusForStage(openB), 'open'); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8db5dc9..8f66c33 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -996,6 +996,50 @@ export interface ReorderPipelineStagesRequest { stageIds: string[]; } +export type PipelineMergeLeadFormMode = 'retarget_all'; + +export interface PipelineMergePreviewStageDto { + id: string; + name: string; + isWon: boolean; + isLost: boolean; + dealCount: number; +} + +export interface PipelineMergePreviewTargetDto { + id: string; + name: string; + isDefault: boolean; + stages: Array<{ + id: string; + name: string; + isWon: boolean; + isLost: boolean; + }>; +} + +export interface PipelineMergePreviewDto { + source: { id: string; name: string; isDefault: boolean }; + stages: PipelineMergePreviewStageDto[]; + dealCount: number; + leadForms: Array<{ id: string; name: string; isActive: boolean }>; + targets: PipelineMergePreviewTargetDto[]; +} + +export interface MergePipelineRequest { + targetPipelineId: string; + stageMap: Record; + leadFormMode?: PipelineMergeLeadFormMode; +} + +export interface MergePipelineResultDto { + ok: true; + targetPipelineId: string; + movedDeals: number; + retargetedLeadForms: number; + deletedPipelineId: string; +} + export interface CreateDealRequest { title: string; pipelineId?: string;