Archived
编排层①:改图入口下沉画布 + 活动契约面全链打通
① 已发布包就地给「起草新版本并去编辑」,不用去页头找; ② 修一个我自己引入的 bug:卡点还原把审批点的「退回调整」牌子当成边,凭空造出 e-A5-A4 —— 卡点只认亲、不新建(旧包有这条边才认);实测编辑器往返 16 边 14 节点无损; ③ 活动契约面接进治理台:列表出参带必填入参/输出口/声明依赖,活动详情摆成一屏, 导出依赖文件一并带上 —— 开发一个活动要照着的东西,现在有一处能看全。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -1603,6 +1603,24 @@ export declare const listActivitiesRoute: {
|
|||||||
version: z.ZodNumber;
|
version: z.ZodNumber;
|
||||||
effective: z.ZodBoolean;
|
effective: z.ZodBoolean;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
|
contract: z.ZodObject<{
|
||||||
|
requiredInputs: z.ZodArray<z.ZodString>;
|
||||||
|
inputFields: z.ZodArray<z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
type: z.ZodString;
|
||||||
|
required: z.ZodBoolean;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
outputPorts: z.ZodArray<z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
description: z.ZodOptional<z.ZodString>;
|
||||||
|
fields: z.ZodArray<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
tools: z.ZodArray<z.ZodString>;
|
||||||
|
models: z.ZodArray<z.ZodString>;
|
||||||
|
objects: z.ZodArray<z.ZodString>;
|
||||||
|
roles: z.ZodArray<z.ZodString>;
|
||||||
|
humanCallBudget: z.ZodNumber;
|
||||||
|
}, z.core.$strip>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -355,8 +355,50 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
const denied = await readActivity(c.get('account'));
|
const denied = await readActivity(c.get('account'));
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
if (denied) return c.json(denied, denyStatus(denied));
|
||||||
const cards = await container.registry.list();
|
const cards = await container.registry.list();
|
||||||
|
/*
|
||||||
|
* 契约面随卡一起给 —— 开发一个活动要照着的东西全在清单里:
|
||||||
|
* 七件契约的入参与出口、声明的工具/模型/域对象/岗位。
|
||||||
|
* 编排的人也要看它:引用一个活动却不知道它吃什么,边映射就只能靠猜。
|
||||||
|
*/
|
||||||
|
const faces = await Promise.all(
|
||||||
|
cards.map(async (card) => {
|
||||||
|
const manifest = await container.registry.manifestOf(card.activityId);
|
||||||
|
const data = manifest.contract.input.data as {
|
||||||
|
required?: string[];
|
||||||
|
properties?: Record<string, { type?: string }>;
|
||||||
|
};
|
||||||
|
const required = data.required ?? [];
|
||||||
|
const props = data.properties ?? {};
|
||||||
|
const portFields = (payload: unknown) => {
|
||||||
|
const p = payload as { properties?: Record<string, unknown> } | undefined;
|
||||||
|
return Object.keys(p?.properties ?? {});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
requiredInputs: required,
|
||||||
|
inputFields: Object.entries(props).map(([name, spec]) => ({
|
||||||
|
name,
|
||||||
|
type: spec.type ?? 'unknown',
|
||||||
|
required: required.includes(name),
|
||||||
|
})),
|
||||||
|
// 必发口与选择组一并列出:调用方只凭口名与 schema 就能衔接下游
|
||||||
|
outputPorts: [
|
||||||
|
...manifest.contract.output.mandatory,
|
||||||
|
...manifest.contract.output.selective.flatMap((g) => g.ports),
|
||||||
|
].map((port) => ({
|
||||||
|
name: port.name,
|
||||||
|
...(port.description === undefined ? {} : { description: port.description }),
|
||||||
|
fields: portFields(port.payload),
|
||||||
|
})),
|
||||||
|
tools: manifest.declarations.tools.map((t) => t.name),
|
||||||
|
models: manifest.declarations.models,
|
||||||
|
objects: manifest.declarations.ledger.map((o) => `${o.domain}.${o.object}`),
|
||||||
|
roles: [...manifest.contract.resources.roles],
|
||||||
|
humanCallBudget: manifest.contract.authorization.humanCallBudget,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
return c.json({
|
return c.json({
|
||||||
activities: cards.map((card) => ({
|
activities: cards.map((card, i) => ({
|
||||||
activityId: card.activityId,
|
activityId: card.activityId,
|
||||||
name: card.name,
|
name: card.name,
|
||||||
category: card.category,
|
category: card.category,
|
||||||
@@ -372,6 +414,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
version: a.version,
|
version: a.version,
|
||||||
effective: a.effective,
|
effective: a.effective,
|
||||||
})),
|
})),
|
||||||
|
contract: faces[i]!,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,3 +35,9 @@
|
|||||||
覆盖只能是显式的 `rollbackAsset` 治理动作
|
覆盖只能是显式的 `rollbackAsset` 治理动作
|
||||||
- **进化必过治理**:候选 → 回放 → 人放行,状态机不许跳过回放
|
- **进化必过治理**:候选 → 回放 → 人放行,状态机不许跳过回放
|
||||||
- **安装恒为新增、版本并存**:卸载只置 state,在途任务按其启动时版本跑完
|
- **安装恒为新增、版本并存**:卸载只置 state,在途任务按其启动时版本跑完
|
||||||
|
|
||||||
|
近变(2026-07-28):模块面未变。app 层的 `GET /api/gov/activities` 出参加 `contract` 面
|
||||||
|
(必填入参 / 入参字段 / 输出口与载荷字段 / 声明的工具·模型·域对象·岗位 / 调人预算),
|
||||||
|
由 `manifestOf` 逐个读清单组装。此前治理台看不到活动的契约:
|
||||||
|
编排的人引用一个活动却不知道它吃什么,边映射只能靠猜;
|
||||||
|
要开发一个活动的人也没有一处能照着看。
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
|
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
|
||||||
*/
|
*/
|
||||||
import { Ico } from '@web-shared/Ico.tsx';
|
import { Ico } from '@web-shared/Ico.tsx';
|
||||||
import { Card, Chip, Clear, Entry, Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
import { Card, Chip, Clear, Entry, Note, PageHead, Prop, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
||||||
import { PARA } from '@web-shared/gov/tone.ts';
|
import { PARA } from '@web-shared/gov/tone.ts';
|
||||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||||
import {
|
import {
|
||||||
@@ -98,6 +98,60 @@ export function ActPage({ id, nav }: { id: string; nav: Nav }) {
|
|||||||
}
|
}
|
||||||
desc={PARA[a.paradigm].full + ' —— 活动是系统外开发、经此安装的外装件。'}
|
desc={PARA[a.paradigm].full + ' —— 活动是系统外开发、经此安装的外装件。'}
|
||||||
/>
|
/>
|
||||||
|
{/*
|
||||||
|
契约面 —— 开发这个活动要照着的东西:吃什么、吐什么、用谁的工具、动哪些域对象。
|
||||||
|
它排在版本谱系前面:先看清契约,再看装了哪几版。
|
||||||
|
*/}
|
||||||
|
<Card ico="file-text" title="契约面 · 开发它要照着的" extra={`调人预算 ${a.contract.humanCallBudget} 次`}>
|
||||||
|
<Prop ico="arrow-left-right" title="入参" extra={`${a.contract.inputFields.length} 个字段`}>
|
||||||
|
{a.contract.inputFields.length === 0 ? (
|
||||||
|
<Clear>不吃结构化入参 —— 只凭委托消息启动。</Clear>
|
||||||
|
) : (
|
||||||
|
<div className="g-rows">
|
||||||
|
{a.contract.inputFields.map((f) => (
|
||||||
|
<div key={f.name}>
|
||||||
|
<em>
|
||||||
|
{f.name}
|
||||||
|
{f.required && <Chip tone="warn">必填</Chip>}
|
||||||
|
</em>
|
||||||
|
<b className="mono">{f.type}</b>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Prop>
|
||||||
|
<Prop ico="send" title="输出口" extra="口名 + 载荷字段即下游可衔接的全部">
|
||||||
|
<div className="g-rows">
|
||||||
|
{a.contract.outputPorts.map((port) => (
|
||||||
|
<div key={port.name}>
|
||||||
|
<em>{port.name}</em>
|
||||||
|
<b className="mono">{port.fields.join(' · ') || '(无字段)'}</b>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{a.contract.outputPorts.length === 0 && <Clear>没有声明输出口 —— 下游无从衔接,发布会被拦。</Clear>}
|
||||||
|
</div>
|
||||||
|
</Prop>
|
||||||
|
<Prop ico="wrench" title="声明的依赖" extra="授权在白名单与契约,不在活动代码">
|
||||||
|
<div className="g-rows">
|
||||||
|
<div>
|
||||||
|
<em>工具</em>
|
||||||
|
<b className="mono">{a.contract.tools.join(' · ') || '(不调工具)'}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<em>模型</em>
|
||||||
|
<b className="mono">{a.contract.models.join(' · ') || '(不调模型)'}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<em>岗位</em>
|
||||||
|
<b className="mono">{a.contract.roles.join(' · ') || '(未声明)'}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<em>域对象</em>
|
||||||
|
<b className="mono">{a.contract.objects.join(' · ') || '(不读写台账)'}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Prop>
|
||||||
|
</Card>
|
||||||
<Card ico="package" title="包版本 · 安装两审">
|
<Card ico="package" title="包版本 · 安装两审">
|
||||||
<div className="g-rows">
|
<div className="g-rows">
|
||||||
{a.packageVersions.map((p) => (
|
{a.packageVersions.map((p) => (
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ import {
|
|||||||
Sec,
|
Sec,
|
||||||
Seg,
|
Seg,
|
||||||
St,
|
St,
|
||||||
|
Todo,
|
||||||
} from '@web-shared/gov/primitives.tsx';
|
} from '@web-shared/gov/primitives.tsx';
|
||||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||||
import { useGovActivities, useGovPackages, useGovPkgReports, useGovStaff, useGovTriggers } from '@web-shared/gov/gov-api.ts';
|
import { useGovActivities, useGovPackages, useGovPkgReports, useGovStaff, useGovTriggers } from '@web-shared/gov/gov-api.ts';
|
||||||
|
import type { GovActivities } from '@web-shared/gov/gov-api.ts';
|
||||||
import { FlowFigureBlock } from '@web-shared/gov/flow/FlowFigure.tsx';
|
import { FlowFigureBlock } from '@web-shared/gov/flow/FlowFigure.tsx';
|
||||||
import { packageToGraph } from '@web-shared/gov/flow/pkg-to-graph.ts';
|
import { packageToGraph } from '@web-shared/gov/flow/pkg-to-graph.ts';
|
||||||
import { mergeGraphIntoPackage } from '@web-shared/gov/flow/graph-to-pkg.ts';
|
import { mergeGraphIntoPackage } from '@web-shared/gov/flow/graph-to-pkg.ts';
|
||||||
@@ -183,12 +185,14 @@ export function PkgPage({ id, nav }: { id: string; nav: Nav }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 本页要用的活动字段面 —— 与 `GovActivitiesOutput` 的一致子集(含契约面,导出依赖文件要它) */
|
||||||
type ActivityRow = {
|
type ActivityRow = {
|
||||||
activityId: string;
|
activityId: string;
|
||||||
name: string;
|
name: string;
|
||||||
paradigm: string;
|
paradigm: string;
|
||||||
packageVersions: ReadonlyArray<{ version: string }>;
|
packageVersions: ReadonlyArray<{ version: string }>;
|
||||||
assetVersions: ReadonlyArray<{ version: number; effective: boolean }>;
|
assetVersions: ReadonlyArray<{ version: number; effective: boolean }>;
|
||||||
|
contract: GovActivities['activities'][number]['contract'];
|
||||||
};
|
};
|
||||||
|
|
||||||
function Orchestrator({
|
function Orchestrator({
|
||||||
@@ -321,6 +325,7 @@ function Orchestrator({
|
|||||||
* 一并成一份 JSON。**依赖文件即包清单骨架**,开发者对着声明在系统外开发活动。
|
* 一并成一份 JSON。**依赖文件即包清单骨架**,开发者对着声明在系统外开发活动。
|
||||||
*/
|
*/
|
||||||
const exportDeps = () => {
|
const exportDeps = () => {
|
||||||
|
const actContract = new Map(activities.map((a) => [a.activityId, a.contract]));
|
||||||
const doc = {
|
const doc = {
|
||||||
package: { id: pkg.id, name: pkg.name, version: pkg.version },
|
package: { id: pkg.id, name: pkg.name, version: pkg.version },
|
||||||
callInterface: pkg.callInterface,
|
callInterface: pkg.callInterface,
|
||||||
@@ -331,8 +336,14 @@ function Orchestrator({
|
|||||||
label: n.label,
|
label: n.label,
|
||||||
activityId: n.activityId,
|
activityId: n.activityId,
|
||||||
owner: n.owner,
|
owner: n.owner,
|
||||||
|
// 编排上手写的注记(给人读)
|
||||||
declaredIn: n.in ?? [],
|
declaredIn: n.in ?? [],
|
||||||
declaredOut: n.out ?? [],
|
declaredOut: n.out ?? [],
|
||||||
|
/*
|
||||||
|
* 活动库里那份**真契约**:必填入参、字段、输出口、声明的工具/模型/域对象。
|
||||||
|
* 开发者照着它写实现;只给注记的话,依赖文件说不清这个活动到底要什么。
|
||||||
|
*/
|
||||||
|
contract: actContract.get(n.activityId) ?? null,
|
||||||
upstreamAssembly: pkg.edges
|
upstreamAssembly: pkg.edges
|
||||||
.filter((e) => e.to === n.id)
|
.filter((e) => e.to === n.id)
|
||||||
.map((e) => ({ from: e.from, mapping: e.mapping })),
|
.map((e) => ({ from: e.from, mapping: e.mapping })),
|
||||||
@@ -480,8 +491,37 @@ function Orchestrator({
|
|||||||
<Sec
|
<Sec
|
||||||
ico="route"
|
ico="route"
|
||||||
title="编排工作区"
|
title="编排工作区"
|
||||||
extra={published ? '已发布版本只读 —— 右上「起草新版本」后可改' : '点画布上的活动 → 右栏属性卡直接改;「编辑」进手绘模式'}
|
extra={published ? '已发布版本只读 —— 改图要另起一版' : '点画布上的活动 → 右栏属性卡直接改;「编辑」进手绘模式'}
|
||||||
>
|
>
|
||||||
|
{/*
|
||||||
|
已发布不可回改是规矩,但「去哪儿改」不该让人自己找 ——
|
||||||
|
就地把路指出来:一句为什么 + 一颗按钮,点了直接建稿并跳过去。
|
||||||
|
*/}
|
||||||
|
{published && (
|
||||||
|
<div className="g-todo">
|
||||||
|
<Todo
|
||||||
|
tone="warn"
|
||||||
|
ico="square-pen"
|
||||||
|
title={`v${pkg.version} 已发布,画布只读`}
|
||||||
|
desc={`改图的路是克隆成 v${nextVersion} 草稿再编排 —— 手绘几何与全部声明一并带走,本版与在途工作流不受影响。${
|
||||||
|
draftSibling === undefined ? '' : `(已有 v${draftSibling.version} 草稿在,直接去那儿改。)`
|
||||||
|
}`}
|
||||||
|
acts={[
|
||||||
|
draftSibling === undefined
|
||||||
|
? {
|
||||||
|
label: draftNext.isPending ? '建稿中…' : `起草 v${nextVersion} 并去编辑`,
|
||||||
|
ico: 'square-pen',
|
||||||
|
onClick: () => draftNext.mutate(),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: `去草稿 v${draftSibling.version} 编辑`,
|
||||||
|
ico: 'square-pen',
|
||||||
|
onClick: () => nav.push({ k: 'pkg', id: `${draftSibling.id}@${draftSibling.version}` }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="g-orch">
|
<div className="g-orch">
|
||||||
<div className="g-canvas" ref={canvasRef}>
|
<div className="g-canvas" ref={canvasRef}>
|
||||||
<div className="process-desk">
|
<div className="process-desk">
|
||||||
|
|||||||
@@ -77,6 +77,19 @@ describe('mergeGraphIntoPackage', () => {
|
|||||||
expect(diagram.nodes.map((n) => n.id)).toContain('c1');
|
expect(diagram.nodes.map((n) => n.id)).toContain('c1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('卡点只认亲、不新建 —— 审批点的驳回牌子不该被当成一条边', () => {
|
||||||
|
// 「审批点 → 退回卡点(to: A)」在旧包里没有对应的边:它是 reject 声明的呈现
|
||||||
|
const withReject: FlowGraph = {
|
||||||
|
...graph,
|
||||||
|
nodes: [...graph.nodes, { id: 'AP', kind: 'activity', label: '审批', flavor: 'approval' }],
|
||||||
|
edges: [...graph.edges, { from: 'AP', to: 'c1', label: '不通过' }],
|
||||||
|
};
|
||||||
|
const merged = mergeGraphIntoPackage(basePkg, withReject);
|
||||||
|
expect(merged.edges.some((e) => e.from === 'AP' && e.to === 'A')).toBe(false);
|
||||||
|
// 旧包里有的回边照旧认得出来
|
||||||
|
expect(merged.edges.some((e) => e.from === 'G' && e.to === 'A')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('画布装不下的声明原样保留:边映射与负责人策略', () => {
|
it('画布装不下的声明原样保留:边映射与负责人策略', () => {
|
||||||
const merged = mergeGraphIntoPackage(basePkg, graph);
|
const merged = mergeGraphIntoPackage(basePkg, graph);
|
||||||
expect(merged.edges.find((e) => e.id === 'e-s-a')?.mapping).toHaveLength(1);
|
expect(merged.edges.find((e) => e.id === 'e-s-a')?.mapping).toHaveLength(1);
|
||||||
|
|||||||
@@ -112,8 +112,13 @@ export function mergeGraphIntoPackage(
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* 卡点是**回跳的呈现**:图上不画长线,画一枚「返回调整→A4」的小牌子。
|
* 卡点是**回跳的呈现**:图上不画长线,画一枚「返回调整→A4」的小牌子。
|
||||||
* 所以「X → 卡点(to: Y)」就是语义边 X→Y —— 落包时把它还原回去,
|
* 「X → 卡点(to: Y)」要还原成语义边 X→Y,否则一存盘回边就没了
|
||||||
* 否则一存盘回边就没了(卡点不入语义,这条边两端有一端不在 semanticIds 里,会被滤掉)。
|
* (卡点不入语义,这条边有一端不在 semanticIds 里,会被滤掉)。
|
||||||
|
*
|
||||||
|
* 但还原只用于**认亲**,不用于**新建**:只有旧包里确有 X→Y 这条边才认。
|
||||||
|
* 不设这道闸,审批点的「退回调整→A4」卡点(它是 `reject` 声明的呈现、不是边)
|
||||||
|
* 会被当成 A5→A4 凭空造出一条边来 —— 实测就多出过一条 `e-A5-A4`。
|
||||||
|
* 真要新连一条边,画一条线;卡点只是牌子。
|
||||||
*/
|
*/
|
||||||
const calloutTarget = new Map(
|
const calloutTarget = new Map(
|
||||||
graph.nodes
|
graph.nodes
|
||||||
@@ -122,7 +127,8 @@ export function mergeGraphIntoPackage(
|
|||||||
);
|
);
|
||||||
const resolved = graph.edges.map((e) => {
|
const resolved = graph.edges.map((e) => {
|
||||||
const via = calloutTarget.get(e.to);
|
const via = calloutTarget.get(e.to);
|
||||||
return via === undefined ? e : { ...e, to: via };
|
if (via === undefined) return e;
|
||||||
|
return priorEdge.has(`${e.from}→${via}`) ? { ...e, to: via } : e;
|
||||||
});
|
});
|
||||||
|
|
||||||
const edges: Edge[] = resolved
|
const edges: Edge[] = resolved
|
||||||
|
|||||||
@@ -332,6 +332,20 @@ export const listActivitiesRoute = {
|
|||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
packageVersions: z.array(z.object({ version: z.string(), state: z.string(), installedAt: z.string() })),
|
packageVersions: z.array(z.object({ version: z.string(), state: z.string(), installedAt: z.string() })),
|
||||||
assetVersions: z.array(z.object({ kind: z.string(), version: z.number().int(), effective: z.boolean() })),
|
assetVersions: z.array(z.object({ kind: z.string(), version: z.number().int(), effective: z.boolean() })),
|
||||||
|
/**
|
||||||
|
* 契约面 —— 开发这个活动要照着的东西:吃什么、吐什么、用谁的工具、动哪些域对象。
|
||||||
|
* 治理台不给这一层,「编排定契约」就只是句口号:编排的人看不到自己引用的活动要什么。
|
||||||
|
*/
|
||||||
|
contract: z.object({
|
||||||
|
requiredInputs: z.array(z.string()),
|
||||||
|
inputFields: z.array(z.object({ name: z.string(), type: z.string(), required: z.boolean() })),
|
||||||
|
outputPorts: z.array(z.object({ name: z.string(), description: z.string().optional(), fields: z.array(z.string()) })),
|
||||||
|
tools: z.array(z.string()),
|
||||||
|
models: z.array(z.string()),
|
||||||
|
objects: z.array(z.string()),
|
||||||
|
roles: z.array(z.string()),
|
||||||
|
humanCallBudget: z.number().int(),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user