diff --git a/docs/superpowers/plans/2026-10-12-pipeline-merge.md b/docs/superpowers/plans/2026-10-12-pipeline-merge.md new file mode 100644 index 0000000..805279e --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-pipeline-merge.md @@ -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; + 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, +): '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}» را به ستون برد/باخت مقصد نگاشت نکنید.`); + } + } +} +``` + +- [ ] **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; + + @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('target'); +const targetId = ref(''); +const preview = ref(null); +const stageMap = ref>({}); +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( + `/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`.