Files
CRM_Montzar/docs/superpowers/plans/2026-10-12-pipeline-merge.md
T
2026-10-12 20:10:03 +03:30

517 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.