docs: add pipeline merge implementation plan

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-12 20:10:03 +03:30
co-authored by Cursor
parent ffae63edbf
commit 91eb9be1d4
@@ -0,0 +1,516 @@
# 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` (needs `apps/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` (near `PipelineDto` / deal types ~960–1050)
- Modify: `apps/api/package.json` (`test` script)
**Interfaces:**
- Produces:
- `PipelineMergeLeadFormMode = 'retarget_all'`
- `PipelineMergePreviewDto`, `MergePipelineRequest`, `MergePipelineResultDto`
- `assertStageMap(sourceStages, targetStages, stageMap): void` throws `Error` with Persian message
- `dealStatusForStage(stage: { isWon: boolean; isLost: boolean }): 'open' | 'won' | 'lost'`
- [ ] **Step 1: Add shared types** in `packages/shared/src/index.ts` after `PipelineDto` / board types:
```ts
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`:
```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)
```powershell
cd apps/api; npx tsx --test test/pipeline-merge.test.ts
```
- [ ] **Step 4: Implement util** — `apps/api/src/pipelines/pipeline-merge.util.ts`:
```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`.
```powershell
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**
```powershell
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**
```ts
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`:
1. Load source pipeline + stages (404 if missing / wrong org).
2. Count deals per stage + total.
3. `leadForm.findMany({ where: { organizationId, pipelineId: sourceId } })`.
4. Load other pipelines as `targets` with stages.
5. Return `PipelineMergePreviewDto`.
`merge`:
1. Load source + target (same org); reject if same id or missing.
2. `assertStageMap` inside try → `BadRequestException` with message.
3. `leadFormMode` default `'retarget_all'`; reject other values.
4. `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 set `closedAt: new Date()` if previously open; when becoming open set `closedAt: null`.
- `tx.leadForm.updateMany({ where: { organizationId, pipelineId: sourceId }, data: { pipelineId: targetId } })`
- If `source.isDefault`: set target `isDefault: true`, source `isDefault: false` (and clear other defaults if needed — org should have one default: `updateMany isDefault true → false` then set target true).
- `tx.pipeline.delete({ where: { id: sourceId } })` (stages cascade; deals already moved so Restrict on stage is fine).
5. Return `MergePipelineResultDto` with 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 `:pipelineId` routes)
```ts
@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**
```powershell
npm run typecheck -w @mento/api
```
Expected: EXIT 0
- [ ] **Step 5: Commit**
```powershell
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` (after `settings/lead-stages` block ~329)
- Modify: `apps/web/src/lib/nav-hubs.ts` (settings cards + optional sales card)
- Modify: `apps/web/src/layouts/AppLayout.vue` if `settingsActive` path list needs `/settings/pipelines`
**Interfaces:**
- Produces: route name `pipeline-settings`, path `/settings/pipelines`
- Opens `PipelineMergeWizard` (Task 4) via prop/emit
- [ ] **Step 1: Router**
```ts
{
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 under `sales.cards` with `admin: true`):
```ts
{
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 if `pipelines.length < 2`), «کاریز جدید» reusing `POST /pipelines` like board. Pattern after `LeadStagesSettingsView.vue` (`UiPageHeader`, `UiCard`, RTL).
- [ ] **Step 4: vue-tsc**
```powershell
npx vue-tsc --noEmit -p apps/web/tsconfig.json
```
- [ ] **Step 5: Commit**
```powershell
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**
```ts
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`**
```ts
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** — `UiSelect` of `preview.targets`; show dependency summary (dealCount, leadForms length, isDefault warning).
- [ ] **Step 4: Map step** — for each `preview.stages` row, select among compatible target stages:
```ts
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 →
```ts
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"`). On `done`, 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**
```powershell
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**
```powershell
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.pipelineId` has no Prisma FK; `updateMany` by string id is correct.
- Deal `stage` Restrict requires moves before pipeline/stage deletion — covered in Task 2.
- Route `GET board` stays before `:pipelineId/*` — existing order preserved; new routes are under `:pipelineId/merge-preview` and do not collide with `board`.