17 KiB
Pipeline Merge Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Allow admins to merge one sales pipeline into another with manual stage mapping, retarget lead forms, transfer default flag, and delete the source pipeline safely.
Architecture: Pure validation helpers in apps/api/src/pipelines/pipeline-merge.util.ts (unit-tested). PipelinesService.mergePreview / merge run in one Prisma transaction. New settings page PipelineSettingsView.vue with a multi-step merge wizard; entry from settings hub + sales hub.
Tech Stack: NestJS, Prisma, class-validator, @mento/shared, Vue 3, Vue Router, existing UiModal / UiSelect / UiButton.
Global Constraints
- Package manager: npm workspaces (
npm run … -w @mento/…); never pnpm/yarn - Prisma only from
apps/api(needsapps/api/.env) - API roles for mutate:
@Roles('owner', 'admin')(same as create pipeline) - Lead forms v1: only
leadFormMode: 'retarget_all' - Won/lost source stages map only to won/lost target stages
- Spec:
docs/superpowers/specs/2026-10-12-pipeline-merge-design.md - After code edits:
graphify update .(may crash; non-blocking) - Shell is PowerShell: use
;not&&
File Map
| File | Responsibility |
|---|---|
packages/shared/src/index.ts |
DTOs for preview + merge request/response |
apps/api/src/pipelines/pipeline-merge.util.ts |
Pure stage-map validation + status from stage flags |
apps/api/test/pipeline-merge.test.ts |
Unit tests for util |
apps/api/src/pipelines/dto.ts |
MergePipelineDto |
apps/api/src/pipelines/pipelines.service.ts |
mergePreview, merge |
apps/api/src/pipelines/pipelines.controller.ts |
GET/POST merge routes |
apps/api/package.json |
Register new test file in test script |
apps/web/src/views/PipelineSettingsView.vue |
List pipelines + open merge wizard |
apps/web/src/components/pipeline/PipelineMergeWizard.vue |
Multi-step merge UI |
apps/web/src/router/index.ts |
Route settings/pipelines |
apps/web/src/lib/nav-hubs.ts |
Hub cards to the new page |
Task 1: Shared types + pure merge validation (TDD)
Files:
- Create:
apps/api/src/pipelines/pipeline-merge.util.ts - Create:
apps/api/test/pipeline-merge.test.ts - Modify:
packages/shared/src/index.ts(nearPipelineDto/ deal types ~960–1050) - Modify:
apps/api/package.json(testscript)
Interfaces:
-
Produces:
PipelineMergeLeadFormMode = 'retarget_all'PipelineMergePreviewDto,MergePipelineRequest,MergePipelineResultDtoassertStageMap(sourceStages, targetStages, stageMap): voidthrowsErrorwith Persian messagedealStatusForStage(stage: { isWon: boolean; isLost: boolean }): 'open' | 'won' | 'lost'
-
Step 1: Add shared types in
packages/shared/src/index.tsafterPipelineDto/ board types:
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<string, string>;
leadFormMode?: PipelineMergeLeadFormMode;
}
export interface MergePipelineResultDto {
ok: true;
targetPipelineId: string;
movedDeals: number;
retargetedLeadForms: number;
deletedPipelineId: string;
}
- Step 2: Write failing unit tests — create
apps/api/test/pipeline-merge.test.ts:
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');
});
});
- Step 3: Run test — expect FAIL (module missing)
cd apps/api; npx tsx --test test/pipeline-merge.test.ts
- Step 4: Implement util —
apps/api/src/pipelines/pipeline-merge.util.ts:
export type MergeStageLike = {
id: string;
name: string;
isWon: boolean;
isLost: boolean;
};
export function dealStatusForStage(
stage: Pick<MergeStageLike, 'isWon' | 'isLost'>,
): 'open' | 'won' | 'lost' {
if (stage.isWon) return 'won';
if (stage.isLost) return 'lost';
return 'open';
}
export function assertStageMap(
sourceStages: MergeStageLike[],
targetStages: MergeStageLike[],
stageMap: Record<string, string>,
): 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}» را به ستون برد/باخت مقصد نگاشت نکنید.`);
}
}
}
- Step 5: Register test + run pass
In apps/api/package.json test script, append test/pipeline-merge.test.ts.
cd apps/api; npx tsx --test test/pipeline-merge.test.ts
npm run build -w @mento/shared
Expected: all tests PASS; shared builds.
- Step 6: Commit
git add packages/shared/src/index.ts apps/api/src/pipelines/pipeline-merge.util.ts apps/api/test/pipeline-merge.test.ts apps/api/package.json
git commit -m "feat(pipelines): merge stage-map util and shared DTOs"
Task 2: API merge-preview + merge
Files:
- Modify:
apps/api/src/pipelines/dto.ts - Modify:
apps/api/src/pipelines/pipelines.service.ts - Modify:
apps/api/src/pipelines/pipelines.controller.ts
Interfaces:
-
Consumes:
assertStageMap,dealStatusForStage, shared DTOs -
Produces:
PipelinesService.mergePreview(orgId, sourceId),PipelinesService.merge(orgId, sourceId, dto) -
Step 1: DTO
export class MergePipelineDto {
@IsString()
@MinLength(1)
targetPipelineId!: string;
@IsObject()
stageMap!: Record<string, string>;
@IsOptional()
@IsString()
leadFormMode?: 'retarget_all';
}
- Step 2: Service methods (add to
PipelinesService)
mergePreview:
- Load source pipeline + stages (404 if missing / wrong org).
- Count deals per stage + total.
leadForm.findMany({ where: { organizationId, pipelineId: sourceId } }).- Load other pipelines as
targetswith stages. - Return
PipelineMergePreviewDto.
merge:
- Load source + target (same org); reject if same id or missing.
assertStageMapinside try →BadRequestExceptionwith message.leadFormModedefault'retarget_all'; reject other values.prisma.$transaction(async (tx) => { ... }):- For each source stage id in map:
tx.deal.updateMany({ where: { organizationId, pipelineId: sourceId, stageId: src }, data: { pipelineId: targetId, stageId: tgt, status: dealStatusForStage(tgtStage), closedAt: status==='open' ? null : undefined } })
Prefer per-stage updates so status/closedAt are correct. For closedAt when becoming won/lost setclosedAt: new Date()if previously open; when becoming open setclosedAt: null. tx.leadForm.updateMany({ where: { organizationId, pipelineId: sourceId }, data: { pipelineId: targetId } })- If
source.isDefault: set targetisDefault: true, sourceisDefault: false(and clear other defaults if needed — org should have one default:updateMany isDefault true → falsethen set target true). tx.pipeline.delete({ where: { id: sourceId } })(stages cascade; deals already moved so Restrict on stage is fine).
- For each source stage id in map:
- Return
MergePipelineResultDtowith counts.
Important: update deals before deleting pipeline; never delete while deals still point at source stages.
- Step 3: Controller routes (keep concrete paths; place near other
:pipelineIdroutes)
@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);
}
- Step 4: Typecheck API
npm run typecheck -w @mento/api
Expected: EXIT 0
- Step 5: Commit
git add apps/api/src/pipelines/dto.ts apps/api/src/pipelines/pipelines.service.ts apps/api/src/pipelines/pipelines.controller.ts
git commit -m "feat(api): pipeline merge preview and merge endpoints"
Task 3: Settings page + nav + router
Files:
- Create:
apps/web/src/views/PipelineSettingsView.vue - Modify:
apps/web/src/router/index.ts(aftersettings/lead-stagesblock ~329) - Modify:
apps/web/src/lib/nav-hubs.ts(settings cards + optional sales card) - Modify:
apps/web/src/layouts/AppLayout.vueifsettingsActivepath list needs/settings/pipelines
Interfaces:
-
Produces: route name
pipeline-settings, path/settings/pipelines -
Opens
PipelineMergeWizard(Task 4) via prop/emit -
Step 1: Router
{
path: 'settings/pipelines',
name: 'pipeline-settings',
component: () => import('@/views/PipelineSettingsView.vue'),
meta: { module: 'pipeline' },
},
(Match neighboring routes’ meta style.)
- Step 2: Nav hub card in
settings.cards(and optionally undersales.cardswithadmin: true):
{
to: '/settings/pipelines',
title: 'مسیر فروش و کاریزها',
desc: 'مدیریت کاریزها و ادغام مسیرهای فروش',
icon: 'filter',
tint: 'mint',
admin: true,
module: 'pipeline',
},
-
Step 3: Minimal settings page — list pipelines from
GET /pipelines, show name, default badge, deal count if available (optional: from preview later). Buttons: «ادغام» (disabled ifpipelines.length < 2), «کاریز جدید» reusingPOST /pipelineslike board. Pattern afterLeadStagesSettingsView.vue(UiPageHeader,UiCard, RTL). -
Step 4: vue-tsc
npx vue-tsc --noEmit -p apps/web/tsconfig.json
- Step 5: Commit
git add apps/web/src/views/PipelineSettingsView.vue apps/web/src/router/index.ts apps/web/src/lib/nav-hubs.ts apps/web/src/layouts/AppLayout.vue
git commit -m "feat(web): pipeline settings page and nav entry"
Task 4: Merge wizard UI
Files:
- Create:
apps/web/src/components/pipeline/PipelineMergeWizard.vue - Modify:
apps/web/src/views/PipelineSettingsView.vue
Interfaces:
-
Props:
open: boolean,sourceId: string | null,pipelines: PipelineDto[] -
Emits:
close,done(after successful merge) -
Calls:
GET /pipelines/:id/merge-preview,POST /pipelines/:id/merge -
Step 1: Wizard steps state
type Step = 'target' | 'map' | 'review';
const step = ref<Step>('target');
const targetId = ref('');
const preview = ref<PipelineMergePreviewDto | null>(null);
const stageMap = ref<Record<string, string>>({});
const busy = ref(false);
const error = ref('');
- Step 2: Load preview when
open && sourceId
watch(
() => [props.open, props.sourceId] as const,
async ([open, id]) => {
if (!open || !id) return;
step.value = 'target';
targetId.value = '';
stageMap.value = {};
error.value = '';
const { data } = await api.get<PipelineMergePreviewDto>(
`/pipelines/${id}/merge-preview`,
);
preview.value = data;
},
);
-
Step 3: Target step —
UiSelectofpreview.targets; show dependency summary (dealCount, leadForms length, isDefault warning). -
Step 4: Map step — for each
preview.stagesrow, select among compatible target stages:
function compatibleTargets(src: PipelineMergePreviewStageDto) {
const t = preview.value?.targets.find((x) => x.id === targetId.value);
if (!t) return [];
return t.stages.filter((s) => {
if (src.isWon) return s.isWon;
if (src.isLost) return s.isLost;
return !s.isWon && !s.isLost;
});
}
Pre-fill when only one compatible option.
- Step 5: Review step — Persian summary:
«N معامله منتقل میشود. M فرم سرنخ به مقصد وصل میشود. کاریز «X» حذف میشود.»
Confirm button →
await api.post(`/pipelines/${sourceId}/merge`, {
targetPipelineId: targetId.value,
stageMap: stageMap.value,
leadFormMode: 'retarget_all',
});
emit('done');
Show API errors under the actions (same axios message pattern as other settings views).
-
Step 6: Wire into settings page with
UiModal(:open="mergeOpen"). Ondone, reload list and close. -
Step 7: Manual check —
npm run dev:web, open/settings/pipelines, merge two test pipelines; confirm source gone and deals on target. -
Step 8: vue-tsc + commit
npx vue-tsc --noEmit -p apps/web/tsconfig.json
git add apps/web/src/components/pipeline/PipelineMergeWizard.vue apps/web/src/views/PipelineSettingsView.vue
git commit -m "feat(web): pipeline merge wizard"
Task 5: Verification + graphify
- Step 1: Full checks
npm run build -w @mento/shared
cd apps/api; npx tsx --test test/pipeline-merge.test.ts; cd ../..
npm run typecheck -w @mento/api
npx vue-tsc --noEmit -p apps/web/tsconfig.json
Expected: all green.
-
Step 2:
graphify update .(ignore crash if known) -
Step 3: Stop — do not push/deploy unless user asks. Paste locked deploy block only when user requests deploy.
Spec coverage checklist
| Spec item | Task |
|---|---|
| Settings entry + manage pipelines page | 3 |
| Merge wizard + stage mapping | 4 |
| Dependency preview (deals, forms, default) | 2 + 4 |
| Won/lost mapping rules | 1 + 4 |
retarget_all lead forms |
2 + 4 |
| Default flag transfer | 2 |
| Transactional delete source | 2 |
| Unit tests for map rules | 1 |
| Admin roles | 2 |
Self-review notes
- No automation rewrite (out of scope).
LeadForm.pipelineIdhas no Prisma FK;updateManyby string id is correct.- Deal
stageRestrict requires moves before pipeline/stage deletion — covered in Task 2. - Route
GET boardstays before:pipelineId/*— existing order preserved; new routes are under:pipelineId/merge-previewand do not collide withboard.