feat(ivr): persiste posição dos nós do editor visual entre recargas
Pedido do usuário: "faz a posição dos nós persistir entre recargas" — o editor visual da PHASE 60 recalculava o layout do zero a cada carga da página, perdendo qualquer arrasto manual assim que a tela recarregava. `IvrMenu.entryPositionX/Y` + `IvrMenuOption.positionX/Y` (nullable, puramente de apresentação — nunca entram no dialplan compilado, só no layout do canvas). Salvos no banco, nunca localStorage: mesma convenção do resto do app, estado compartilhado entre quem quer que edite o tenant, não por navegador/dispositivo. "Salvar alterações" agora lê a posição de verdade do estado de nós do @xyflow/react (reflete arrastos feitos na sessão), não do estado de conteúdo das opções — os dois tinham ficado dessincronizados desde a PHASE 60. Sem posição salva ainda (menu novo, opção recém-adicionada), continua caindo num layout automático em coluna. Testado ponta a ponta: PATCH com coordenadas específicas (incluindo o nó "Entrada"), GET de volta confirma os mesmos valores, e a página carregada de novo com uma sessão real (cookie de login, não só a API crua) já embute essas coordenadas nos props iniciais do componente. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
18
TODO.md
18
TODO.md
@@ -2200,8 +2200,22 @@ para fazer o fluxo de maneira visual usando nodeRED")
|
|||||||
`PATCH` (a mesma chamada que o botão "Salvar" do editor visual
|
`PATCH` (a mesma chamada que o botão "Salvar" do editor visual
|
||||||
faz) — confirmado no banco que a versão 2 do dialplan compilou as
|
faz) — confirmado no banco que a versão 2 do dialplan compilou as
|
||||||
duas opções corretamente, superando a versão 1
|
duas opções corretamente, superando a versão 1
|
||||||
- [ ] Layout do canvas não persiste posição manual dos nós entre
|
- [x] **Layout do canvas agora persiste posição dos nós entre recargas**
|
||||||
recargas (recalculado a cada carga da página)
|
(pedido do usuário: "faz a posição dos nós persistir entre
|
||||||
|
recargas") — `IvrMenu.entryPositionX/Y` + `IvrMenuOption.positionX/Y`
|
||||||
|
(nullable, puramente de apresentação, nunca entram no dialplan
|
||||||
|
compilado). Salvos no banco — nunca `localStorage`, mesma
|
||||||
|
convenção do resto do app (estado compartilhado entre quem edita
|
||||||
|
o tenant, não por navegador) — a cada "Salvar alterações", lendo
|
||||||
|
a posição de verdade do estado de nós do `@xyflow/react` (reflete
|
||||||
|
arrastos da sessão), não do estado de conteúdo das opções. Sem
|
||||||
|
posição salva ainda (menu novo, opção recém-adicionada), cai num
|
||||||
|
layout automático em coluna
|
||||||
|
- [x] Testado ponta a ponta: `PATCH` com coordenadas específicas
|
||||||
|
(incluindo o nó "Entrada"), `GET` de volta confirma os mesmos
|
||||||
|
valores, e a página carregada de novo (SSR, cookie de sessão real)
|
||||||
|
já embute essas coordenadas nos props iniciais do componente —
|
||||||
|
não só a API, o carregamento real da tela também foi verificado
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
|
import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsNumber, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
|
||||||
import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony";
|
import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony";
|
||||||
import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator";
|
import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator";
|
||||||
|
|
||||||
@@ -20,6 +20,16 @@ export class IvrMenuOptionDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|
||||||
|
// Posição do nó no editor visual (PHASE 61) — puramente de
|
||||||
|
// apresentação, nunca afeta o dialplan compilado.
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
positionX?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
positionY?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateIvrMenuDto {
|
export class CreateIvrMenuDto {
|
||||||
@@ -66,4 +76,12 @@ export class UpdateIvrMenuDto {
|
|||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => IvrMenuOptionDto)
|
@Type(() => IvrMenuOptionDto)
|
||||||
options?: IvrMenuOptionDto[];
|
options?: IvrMenuOptionDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
entryPositionX?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
entryPositionY?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ export class IvrMenusController {
|
|||||||
destinationNumber: opt.destinationNumber,
|
destinationNumber: opt.destinationNumber,
|
||||||
destinationContext: opt.destinationContext ?? "default",
|
destinationContext: opt.destinationContext ?? "default",
|
||||||
label: opt.label,
|
label: opt.label,
|
||||||
|
positionX: opt.positionX,
|
||||||
|
positionY: opt.positionY,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
return created;
|
return created;
|
||||||
@@ -220,7 +222,12 @@ export class IvrMenusController {
|
|||||||
const menu = await withTenantContext(prisma, tenantId, async (tx) => {
|
const menu = await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
const updated = await tx.ivrMenu.update({
|
const updated = await tx.ivrMenu.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { ...(dto.name !== undefined ? { name: dto.name } : {}), ...(dto.greeting !== undefined ? { greeting: dto.greeting } : {}) },
|
data: {
|
||||||
|
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||||
|
...(dto.greeting !== undefined ? { greeting: dto.greeting } : {}),
|
||||||
|
...(dto.entryPositionX !== undefined ? { entryPositionX: dto.entryPositionX } : {}),
|
||||||
|
...(dto.entryPositionY !== undefined ? { entryPositionY: dto.entryPositionY } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (dto.options) {
|
if (dto.options) {
|
||||||
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
|
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
|
||||||
@@ -232,6 +239,8 @@ export class IvrMenusController {
|
|||||||
destinationNumber: opt.destinationNumber,
|
destinationNumber: opt.destinationNumber,
|
||||||
destinationContext: opt.destinationContext ?? "default",
|
destinationContext: opt.destinationContext ?? "default",
|
||||||
label: opt.label,
|
label: opt.label,
|
||||||
|
positionX: opt.positionX,
|
||||||
|
positionY: opt.positionY,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface IvrMenuOptionInput {
|
|||||||
digit: string;
|
digit: string;
|
||||||
destinationNumber: string;
|
destinationNumber: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
positionX?: number;
|
||||||
|
positionY?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateIvrMenuInput {
|
export interface CreateIvrMenuInput {
|
||||||
@@ -46,12 +48,16 @@ export async function createIvrMenu(input: CreateIvrMenuInput): Promise<{ ok: tr
|
|||||||
export async function updateIvrMenuOptions(
|
export async function updateIvrMenuOptions(
|
||||||
id: string,
|
id: string,
|
||||||
options: IvrMenuOptionInput[],
|
options: IvrMenuOptionInput[],
|
||||||
|
entryPosition?: { x: number; y: number },
|
||||||
): Promise<{ ok: true; menu: IvrMenu } | { ok: false; error: string }> {
|
): Promise<{ ok: true; menu: IvrMenu } | { ok: false; error: string }> {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
try {
|
try {
|
||||||
const menu = await apiFetch<IvrMenu>(`/ivr-menus/${id}`, session.accessToken, {
|
const menu = await apiFetch<IvrMenu>(`/ivr-menus/${id}`, session.accessToken, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ options }),
|
body: JSON.stringify({
|
||||||
|
options,
|
||||||
|
...(entryPosition ? { entryPositionX: entryPosition.x, entryPositionY: entryPosition.y } : {}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
revalidatePath("/app/telefonia/ivr");
|
revalidatePath("/app/telefonia/ivr");
|
||||||
return { ok: true, menu };
|
return { ok: true, menu };
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ interface OptionState {
|
|||||||
digit: string;
|
digit: string;
|
||||||
destinationNumber: string;
|
destinationNumber: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
positionX: number | null;
|
||||||
|
positionY: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EntryNodeData extends Record<string, unknown> {}
|
interface EntryNodeData extends Record<string, unknown> {}
|
||||||
@@ -108,19 +110,25 @@ const NODE_TYPES: NodeTypes = { entry: EntryNode, option: OptionNode };
|
|||||||
const ENTRY_NODE_ID = "entry";
|
const ENTRY_NODE_ID = "entry";
|
||||||
const COLUMN_WIDTH = 260;
|
const COLUMN_WIDTH = 260;
|
||||||
|
|
||||||
function layoutNodes(options: OptionState[], extensions: Extension[], onChange: OptionNodeData["onChange"], onRemove: OptionNodeData["onRemove"]) {
|
function layoutNodes(
|
||||||
|
options: OptionState[],
|
||||||
|
entryPosition: { x: number; y: number } | null,
|
||||||
|
extensions: Extension[],
|
||||||
|
onChange: OptionNodeData["onChange"],
|
||||||
|
onRemove: OptionNodeData["onRemove"],
|
||||||
|
) {
|
||||||
const usedDigits = options.map((o) => o.digit);
|
const usedDigits = options.map((o) => o.digit);
|
||||||
const entry: Node<EntryNodeData, "entry"> = {
|
const entry: Node<EntryNodeData, "entry"> = {
|
||||||
id: ENTRY_NODE_ID,
|
id: ENTRY_NODE_ID,
|
||||||
type: "entry",
|
type: "entry",
|
||||||
position: { x: (Math.max(options.length, 1) * COLUMN_WIDTH) / 2 - 85, y: 0 },
|
position: entryPosition ?? { x: (Math.max(options.length, 1) * COLUMN_WIDTH) / 2 - 85, y: 0 },
|
||||||
data: {},
|
data: {},
|
||||||
draggable: true,
|
draggable: true,
|
||||||
};
|
};
|
||||||
const optionNodes: Node<OptionNodeData, "option">[] = options.map((option, i) => ({
|
const optionNodes: Node<OptionNodeData, "option">[] = options.map((option, i) => ({
|
||||||
id: option.key,
|
id: option.key,
|
||||||
type: "option",
|
type: "option",
|
||||||
position: { x: i * COLUMN_WIDTH, y: 160 },
|
position: option.positionX != null && option.positionY != null ? { x: option.positionX, y: option.positionY } : { x: i * COLUMN_WIDTH, y: 160 },
|
||||||
data: { option, extensions, usedDigits, onChange, onRemove },
|
data: { option, extensions, usedDigits, onChange, onRemove },
|
||||||
draggable: true,
|
draggable: true,
|
||||||
}));
|
}));
|
||||||
@@ -149,9 +157,21 @@ function nextKey(): string {
|
|||||||
*/
|
*/
|
||||||
export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions: Extension[] }) {
|
export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions: Extension[] }) {
|
||||||
const initialOptions = useMemo<OptionState[]>(
|
const initialOptions = useMemo<OptionState[]>(
|
||||||
() => menu.options.map((o) => ({ key: o.id, digit: o.digit, destinationNumber: o.destinationNumber, label: o.label ?? "" })),
|
() =>
|
||||||
|
menu.options.map((o) => ({
|
||||||
|
key: o.id,
|
||||||
|
digit: o.digit,
|
||||||
|
destinationNumber: o.destinationNumber,
|
||||||
|
label: o.label ?? "",
|
||||||
|
positionX: o.positionX,
|
||||||
|
positionY: o.positionY,
|
||||||
|
})),
|
||||||
[menu.options],
|
[menu.options],
|
||||||
);
|
);
|
||||||
|
const initialEntryPosition = useMemo(
|
||||||
|
() => (menu.entryPositionX != null && menu.entryPositionY != null ? { x: menu.entryPositionX, y: menu.entryPositionY } : null),
|
||||||
|
[menu.entryPositionX, menu.entryPositionY],
|
||||||
|
);
|
||||||
const [options, setOptions] = useState<OptionState[]>(initialOptions);
|
const [options, setOptions] = useState<OptionState[]>(initialOptions);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
@@ -168,7 +188,8 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
||||||
() => layoutNodes(options, extensions, onChange, onRemove),
|
() => layoutNodes(options, initialEntryPosition, extensions, onChange, onRemove),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialEntryPosition só serve pro layout inicial (posição arrastada depois vive em `nodes`, nunca recomputada daqui)
|
||||||
[options, extensions, onChange, onRemove],
|
[options, extensions, onChange, onRemove],
|
||||||
);
|
);
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||||
@@ -191,7 +212,7 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
|
|||||||
setSaved(false);
|
setSaved(false);
|
||||||
const usedDigits = new Set(options.map((o) => o.digit));
|
const usedDigits = new Set(options.map((o) => o.digit));
|
||||||
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1";
|
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1";
|
||||||
setOptions((prev) => [...prev, { key: nextKey(), digit: nextDigit, destinationNumber: "", label: "" }]);
|
setOptions((prev) => [...prev, { key: nextKey(), digit: nextDigit, destinationNumber: "", label: "", positionX: null, positionY: null }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSave() {
|
function onSave() {
|
||||||
@@ -210,14 +231,24 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: IvrMenuOptionInput[] = options.map((o) => ({
|
// Lê a posição de verdade de `nodes` (reflete arrastos feitos nesta
|
||||||
digit: o.digit,
|
// sessão), não de `options` — a única fonte de posição atualizada
|
||||||
destinationNumber: o.destinationNumber.trim(),
|
// pelo react-flow é o estado de nós, nunca `data.option` de volta.
|
||||||
label: o.label.trim() || undefined,
|
const payload: IvrMenuOptionInput[] = options.map((o) => {
|
||||||
}));
|
const node = nodes.find((n) => n.id === o.key);
|
||||||
|
return {
|
||||||
|
digit: o.digit,
|
||||||
|
destinationNumber: o.destinationNumber.trim(),
|
||||||
|
label: o.label.trim() || undefined,
|
||||||
|
positionX: node?.position.x,
|
||||||
|
positionY: node?.position.y,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const entryNode = nodes.find((n) => n.id === ENTRY_NODE_ID);
|
||||||
|
const entryPosition = entryNode ? { x: entryNode.position.x, y: entryNode.position.y } : undefined;
|
||||||
|
|
||||||
setPending(true);
|
setPending(true);
|
||||||
updateIvrMenuOptions(menu.id, payload).then((result) => {
|
updateIvrMenuOptions(menu.id, payload, entryPosition).then((result) => {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export interface IvrMenuOption {
|
|||||||
destinationNumber: string;
|
destinationNumber: string;
|
||||||
destinationContext: string;
|
destinationContext: string;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
|
positionX: number | null;
|
||||||
|
positionY: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IvrMenu {
|
export interface IvrMenu {
|
||||||
@@ -78,6 +80,8 @@ export interface IvrMenu {
|
|||||||
greeting: string | null;
|
greeting: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
entryPositionX: number | null;
|
||||||
|
entryPositionY: number | null;
|
||||||
options: IvrMenuOption[];
|
options: IvrMenuOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,12 +201,23 @@ uma segunda opção adicionada via `PATCH` (mesma chamada que o botão
|
|||||||
"Salvar" do editor visual faz) — confirmado no banco que a versão 2 do
|
"Salvar" do editor visual faz) — confirmado no banco que a versão 2 do
|
||||||
dialplan compilou as duas opções corretamente, superando a versão 1.
|
dialplan compilou as duas opções corretamente, superando a versão 1.
|
||||||
|
|
||||||
|
### Posição dos nós persiste entre recargas (PHASE 61)
|
||||||
|
|
||||||
|
`IvrMenu.entryPositionX/Y` + `IvrMenuOption.positionX/Y` (nullable,
|
||||||
|
puramente de apresentação — nunca entram no dialplan compilado). Salvos
|
||||||
|
no banco (nunca `localStorage`, mesma convenção do resto do app: estado
|
||||||
|
compartilhado entre quem quer que edite o tenant, não por navegador) a
|
||||||
|
cada "Salvar alterações" — o editor lê a posição de verdade do estado de
|
||||||
|
nós do `@xyflow/react` (reflete arrastos feitos na sessão), não do
|
||||||
|
estado de conteúdo das opções. Sem posição salva (menu novo, opção
|
||||||
|
recém-adicionada), cai num layout automático em coluna. Testado ponta a
|
||||||
|
ponta: `PATCH` com coordenadas específicas, `GET` de volta confirma os
|
||||||
|
mesmos valores, e a página carregada de novo (SSR) já embute essas
|
||||||
|
coordenadas nos props iniciais do componente.
|
||||||
|
|
||||||
## O que falta
|
## O que falta
|
||||||
|
|
||||||
- Sem TTS (texto→voz) — só upload de arquivo WAV já gravado.
|
- Sem TTS (texto→voz) — só upload de arquivo WAV já gravado.
|
||||||
- Editor visual não persiste posição manual dos nós entre recargas
|
|
||||||
(layout recalculado a cada carga da página — arrastar só ajuda
|
|
||||||
durante a mesma sessão de edição).
|
|
||||||
- Menu de IVR não suporta sub-menus (uma opção levando a OUTRO IVR) nem
|
- Menu de IVR não suporta sub-menus (uma opção levando a OUTRO IVR) nem
|
||||||
destino "fila" — só ramal, dentro do contexto `default`.
|
destino "fila" — só ramal, dentro do contexto `default`.
|
||||||
- Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID →
|
- Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID →
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- PHASE 61: persistir posição dos nós do editor visual do IVR
|
||||||
|
ALTER TABLE "ivr_menu_options" ADD COLUMN "position_x" DOUBLE PRECISION,
|
||||||
|
ADD COLUMN "position_y" DOUBLE PRECISION;
|
||||||
|
|
||||||
|
ALTER TABLE "ivr_menus" ADD COLUMN "entry_position_x" DOUBLE PRECISION,
|
||||||
|
ADD COLUMN "entry_position_y" DOUBLE PRECISION;
|
||||||
@@ -506,6 +506,12 @@ model IvrMenu {
|
|||||||
|
|
||||||
enabled Boolean @default(true)
|
enabled Boolean @default(true)
|
||||||
|
|
||||||
|
// Posição do nó "Entrada" no editor visual (PHASE 61, secao "Editor
|
||||||
|
// visual" em docs/INBOUND_ROUTES.md) — null até o primeiro "Salvar";
|
||||||
|
// puramente de apresentação, nunca afeta o dialplan compilado.
|
||||||
|
entryPositionX Float? @map("entry_position_x")
|
||||||
|
entryPositionY Float? @map("entry_position_y")
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
@@ -530,6 +536,11 @@ model IvrMenuOption {
|
|||||||
|
|
||||||
label String?
|
label String?
|
||||||
|
|
||||||
|
// Posição do nó desta opção no editor visual (PHASE 61) — mesma
|
||||||
|
// ressalva do IvrMenu.entryPosition* acima: só apresentação.
|
||||||
|
positionX Float? @map("position_x")
|
||||||
|
positionY Float? @map("position_y")
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
|||||||
Reference in New Issue
Block a user