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

17 KiB
Raw Permalink Blame History

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:

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:

  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)
@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 (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

{
  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):
{
  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

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 — 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:

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"). 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

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.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.