docs: add pipeline merge implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`.
|
||||
Reference in New Issue
Block a user