@sleep2agi/commhub-server 0.9.0-preview.147 → 0.9.0-preview.149
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/env-transport-gate-http.test.ts +32 -1
- package/src/node-session-control-http.test.ts +3 -1
- package/src/node-session-control.ts +5 -1
- package/src/requirement-tag-ops-http.test.ts +3 -3
- package/src/requirement-tag-tree-http.test.ts +275 -0
- package/src/requirement-tag-tree.ts +97 -0
- package/src/requirements-errors.ts +8 -0
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-migrate.ts +21 -0
- package/src/requirements.ts +136 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.149",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -19,6 +19,11 @@ let server: ReturnType<typeof Bun.serve>;
|
|
|
19
19
|
let base = "";
|
|
20
20
|
const PLAIN = "n_gate_plain";
|
|
21
21
|
const TLS = "n_gate_tls";
|
|
22
|
+
// #1510 acceptance names three transports: loopback passes, https passes, plain
|
|
23
|
+
// public is refused. log/enforce only ever *special-case* `plain`, so loopback
|
|
24
|
+
// could rot without anyone noticing it is still accepted — seed it as its own
|
|
25
|
+
// node rather than folding it in with TLS, so a regression reads by name.
|
|
26
|
+
const LOOPBACK = "n_gate_loopback";
|
|
22
27
|
|
|
23
28
|
const post = async (path: string, token: string, body: unknown) => {
|
|
24
29
|
const r = await fetch(`${base}${path}`, {
|
|
@@ -50,6 +55,7 @@ beforeAll(async () => {
|
|
|
50
55
|
);
|
|
51
56
|
seed(PLAIN, "plain");
|
|
52
57
|
seed(TLS, "https");
|
|
58
|
+
seed(LOOPBACK, "loopback");
|
|
53
59
|
// The transport is reported on the session row (that is where report_status
|
|
54
60
|
// writes it).
|
|
55
61
|
const session = (nodeId: string, transport: string) => db.run(
|
|
@@ -59,6 +65,7 @@ beforeAll(async () => {
|
|
|
59
65
|
);
|
|
60
66
|
session(PLAIN, "plain");
|
|
61
67
|
session(TLS, "https");
|
|
68
|
+
session(LOOPBACK, "loopback");
|
|
62
69
|
|
|
63
70
|
const mod = await import("./server.js");
|
|
64
71
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
@@ -74,7 +81,7 @@ beforeEach(async () => {
|
|
|
74
81
|
// The lane allows one pending request per node; drain whatever the previous
|
|
75
82
|
// case left so the next one starts clean.
|
|
76
83
|
const { pullNodeSecrets, ackNodeSecrets } = await import("./scoped-secrets.js");
|
|
77
|
-
for (const nodeId of [PLAIN, TLS]) {
|
|
84
|
+
for (const nodeId of [PLAIN, TLS, LOOPBACK]) {
|
|
78
85
|
const daemon = { ok: true as const, networkId: net, daemonNodeId: nodeId, daemonAlias: nodeId };
|
|
79
86
|
const pulled = pullNodeSecrets(daemon) as any;
|
|
80
87
|
for (const r of (pulled?.requests ?? [])) ackNodeSecrets(daemon, r.request_id, null, true);
|
|
@@ -102,6 +109,19 @@ describe("log mode (default)", () => {
|
|
|
102
109
|
expect(after).toBe(before);
|
|
103
110
|
});
|
|
104
111
|
|
|
112
|
+
// #1510 acceptance leg 1: loopback is an allowed transport, so it must neither
|
|
113
|
+
// be refused nor pollute the would-refuse log (the log is the decision input
|
|
114
|
+
// for turning enforce on — a loopback hit in it would read as "something to
|
|
115
|
+
// block" when nothing is).
|
|
116
|
+
test("a put to a loopback node succeeds AND leaves no hit", async () => {
|
|
117
|
+
const before = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
|
|
118
|
+
const r = await post(`/api/nodes/${LOOPBACK}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "LB_KEY", value: "v", revision: 1 });
|
|
119
|
+
expect(r.status).toBe(200);
|
|
120
|
+
expect(r.body.ok).toBe(true);
|
|
121
|
+
const after = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
|
|
122
|
+
expect(after).toBe(before);
|
|
123
|
+
});
|
|
124
|
+
|
|
105
125
|
test("list is not observed (it carries no secret)", async () => {
|
|
106
126
|
const before = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
|
|
107
127
|
await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "list" });
|
|
@@ -123,6 +143,17 @@ describe("enforce mode", () => {
|
|
|
123
143
|
const r = await post(`/api/nodes/${TLS}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "D_KEY", value: "v", revision: 1 });
|
|
124
144
|
expect(r.status).toBe(200);
|
|
125
145
|
});
|
|
146
|
+
|
|
147
|
+
// #1510 acceptance: "loopback 通过 / https 通过 / 明文公网被拒". enforce is
|
|
148
|
+
// defined as *refuse plain* — loopback is a local link and must keep working
|
|
149
|
+
// under enforce, otherwise turning the gate on would break the one transport
|
|
150
|
+
// it exists to protect.
|
|
151
|
+
test("a loopback node passes enforce too", async () => {
|
|
152
|
+
process.env.ANET_SECRET_TRANSPORT_GATE = "enforce";
|
|
153
|
+
const r = await post(`/api/nodes/${LOOPBACK}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "LB2_KEY", value: "v", revision: 1 });
|
|
154
|
+
expect(r.status).toBe(200);
|
|
155
|
+
expect(r.body.ok).toBe(true);
|
|
156
|
+
});
|
|
126
157
|
});
|
|
127
158
|
|
|
128
159
|
describe("off mode", () => {
|
|
@@ -297,7 +297,9 @@ describe("#2017 human-to-exact-Bridge HTTP relay", () => {
|
|
|
297
297
|
test("lease expiry refuses new work and Hub restart loses IDs without replay", async () => {
|
|
298
298
|
const made = await enqueue();
|
|
299
299
|
setSystemTime(new Date(Date.now() + 76_000));
|
|
300
|
-
|
|
300
|
+
// #2014: no live bridge is its own code, so a client can tell "this node has no
|
|
301
|
+
// bridge (upgrade / start it)" from a permission refusal.
|
|
302
|
+
expect((await enqueue()).body.error).toBe("session_control_bridge_missing");
|
|
301
303
|
setSystemTime();
|
|
302
304
|
server.stop(true);
|
|
303
305
|
const { bootServer } = await import("./server.js");
|
|
@@ -198,7 +198,11 @@ export function createNodeSessionControlRelay() {
|
|
|
198
198
|
const n = node(v.node_id);
|
|
199
199
|
if (!n || n.network_id !== v.network_id || !human(tokenId, n)) return fail("session_control_unavailable", 403);
|
|
200
200
|
const b = bridges.get(key(n));
|
|
201
|
-
|
|
201
|
+
// A distinct code for "this node has no live bridge" (never registered, or
|
|
202
|
+
// its 75 s lease expired). #2014 needs it: the client can then say "upgrade
|
|
203
|
+
// the node / start it" instead of the generic "refresh later" it shows for
|
|
204
|
+
// `session_control_unavailable` (which also covers permission refusals).
|
|
205
|
+
if (!b || b.expires <= now || !bound(b.tokenId, n)) return fail("session_control_bridge_missing");
|
|
202
206
|
if (v.expected && !same(v.expected as Identity, b)) return fail("session_control_changed");
|
|
203
207
|
if (entries.size >= CAP || [...entries.values()].some(e => e.nodeId === n.node_id
|
|
204
208
|
&& e.networkId === n.network_id && (e.status === "pending" || e.status === "in_progress"))) {
|
|
@@ -208,7 +208,7 @@ describe("改名 / 合并 / 删除 / 颜色", () => {
|
|
|
208
208
|
const t0 = new Date().toISOString();
|
|
209
209
|
const r = await op(admin.token, { op: "rename", from: "UI", to: "界面" });
|
|
210
210
|
expect(r.status).toBe(200);
|
|
211
|
-
expect(r.body).toEqual({ ok: true, op: "rename", affected: 4 });
|
|
211
|
+
expect(r.body).toEqual({ ok: true, op: "rename", affected: 4, reparented: 0 });
|
|
212
212
|
expect(cardTags("visible")).toEqual(["界面", "交互", "bug"]);
|
|
213
213
|
expect(cardTags("hidden")).toEqual(["界面", "secret"]);
|
|
214
214
|
expect(cardTags("archived")).toEqual(["界面", "旧"]);
|
|
@@ -237,7 +237,7 @@ describe("改名 / 合并 / 删除 / 颜色", () => {
|
|
|
237
237
|
test("合并:多个来源进一个新标签;目标没颜色就继承来源的;来源颜色行删掉", async () => {
|
|
238
238
|
expect((await op(admin.token, { op: "color", tag: "旧", color: "#16a34a" })).status).toBe(200);
|
|
239
239
|
const r = await op(admin.token, { op: "merge", from: ["bug", "旧", "secret", "不存在"], to: "杂项" });
|
|
240
|
-
expect(r.body).toEqual({ ok: true, op: "merge", affected: 3 });
|
|
240
|
+
expect(r.body).toEqual({ ok: true, op: "merge", affected: 3, reparented: 0 });
|
|
241
241
|
expect(cardTags("visible")).toEqual(["界面", "杂项"]);
|
|
242
242
|
expect(cardTags("hidden")).toEqual(["界面", "杂项"]);
|
|
243
243
|
expect(cardTags("archived")).toEqual(["界面", "杂项"]);
|
|
@@ -253,7 +253,7 @@ describe("改名 / 合并 / 删除 / 颜色", () => {
|
|
|
253
253
|
|
|
254
254
|
test("删除:从每张卡上拿掉(卡保留),颜色行删掉,审计带 affected", async () => {
|
|
255
255
|
const r = await op(admin.token, { op: "delete", tag: "界面" });
|
|
256
|
-
expect(r.body).toEqual({ ok: true, op: "delete", affected: 4 });
|
|
256
|
+
expect(r.body).toEqual({ ok: true, op: "delete", affected: 4, reparented: 0 });
|
|
257
257
|
expect(cardTags("visible")).toEqual([]);
|
|
258
258
|
expect(cardTags("both")).toEqual(["TMWork"]);
|
|
259
259
|
expect(db.get("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET).n).toBe(5);
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// 标签树(POST /api/requirements/tags/create + GET /api/requirements/tags 的 nodes + ops 维护层级)
|
|
2
|
+
// —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
|
|
3
|
+
//
|
|
4
|
+
// 场景:Hub 管理员 admin 的网络 NET,另一个不相干的网络 NET2。成员:
|
|
5
|
+
// carol —— task_access='all' 的老成员(能管标签);
|
|
6
|
+
// alice —— scoped 成员(不能管);
|
|
7
|
+
// vic —— viewer;
|
|
8
|
+
// nodeToken —— 节点令牌。
|
|
9
|
+
//
|
|
10
|
+
// 正向:建根标签、建子标签并回读 parent_id/depth;0 任务的子标签能在 nodes 里读到;父只在卡片上出现过时
|
|
11
|
+
// 补登记成顶层(parent_registered=true)且子标签仍挂在它下面;改名 / 合并 / 删除让层级跟着动。
|
|
12
|
+
// 反向:重名 409、父不存在 404(不 root fallback)、超过 4 层 400、scoped / viewer / 节点令牌 403 且一行都不写、
|
|
13
|
+
// 同名标签在别的网络互不相干、坏参数 400、原子性(重名失败后不留半条节点)。
|
|
14
|
+
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
16
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-tag-tree-"));
|
|
21
|
+
let BASE = "";
|
|
22
|
+
let hub: any = null;
|
|
23
|
+
let db: any = null;
|
|
24
|
+
const PW = "TagTreePassw0rd!x";
|
|
25
|
+
let NET = "";
|
|
26
|
+
let NET2 = "";
|
|
27
|
+
let admin = { token: "", id: "" };
|
|
28
|
+
let other = { token: "", id: "" };
|
|
29
|
+
let alice = { token: "", id: "" };
|
|
30
|
+
let carol = { token: "", id: "" };
|
|
31
|
+
let vic = { token: "", id: "" };
|
|
32
|
+
let nodeToken = "";
|
|
33
|
+
|
|
34
|
+
type R = { status: number; body: any };
|
|
35
|
+
async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
|
|
36
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
37
|
+
method,
|
|
38
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
|
|
39
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
40
|
+
});
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
let body: any = null;
|
|
43
|
+
try { body = JSON.parse(text); } catch {}
|
|
44
|
+
return { status: res.status, body };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const create = (token: string, payload: unknown, net = NET) => send(token, "POST", `/api/requirements/tags/create?network_id=${net}`, payload);
|
|
48
|
+
const tagsOf = (token: string, net = NET) => send(token, "GET", `/api/requirements/tags?network_id=${net}`);
|
|
49
|
+
const op = (token: string, payload: unknown, net = NET) => send(token, "POST", `/api/requirements/tags/ops?network_id=${net}`, payload);
|
|
50
|
+
const nodesOf = async (token = admin.token, net = NET): Promise<Record<string, { parent: string | null; depth: number; count: number; registered: boolean }>> => {
|
|
51
|
+
const r = await tagsOf(token, net);
|
|
52
|
+
expect(r.status).toBe(200);
|
|
53
|
+
return Object.fromEntries((r.body.nodes ?? []).map((n: any) => [n.name, { parent: n.parent_id, depth: n.depth, count: n.count, registered: n.registered }]));
|
|
54
|
+
};
|
|
55
|
+
const nodeRows = (net = NET) => db.all("SELECT name, parent_name FROM network_tag_nodes WHERE network_id = ?1 ORDER BY name", [net]);
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
59
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
60
|
+
process.env.HOST = "127.0.0.1";
|
|
61
|
+
({ db } = await import("./db.js"));
|
|
62
|
+
const { createNetworkTokenForNode, register } = await import("./auth.js");
|
|
63
|
+
const stamp = Date.now();
|
|
64
|
+
const a = register(`tt_admin_${stamp}`, PW, undefined, "Admin");
|
|
65
|
+
expect(a.ok).toBe(true);
|
|
66
|
+
admin = { token: a.token!, id: a.user!.user_id };
|
|
67
|
+
NET = a.network_id!;
|
|
68
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
|
|
69
|
+
const b = register(`tt_other_${stamp}`, PW, undefined, "Other");
|
|
70
|
+
expect(b.ok).toBe(true);
|
|
71
|
+
other = { token: b.token!, id: b.user!.user_id };
|
|
72
|
+
NET2 = b.network_id!;
|
|
73
|
+
const ntok = createNetworkTokenForNode(admin.id, NET, "tag-node");
|
|
74
|
+
expect(ntok.ok).toBe(true);
|
|
75
|
+
nodeToken = ntok.token!;
|
|
76
|
+
|
|
77
|
+
const mod: any = await import("./server.js");
|
|
78
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
79
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
80
|
+
|
|
81
|
+
const mk = async (username: string, role: string) => {
|
|
82
|
+
const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role });
|
|
83
|
+
expect(r.status).toBe(200);
|
|
84
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
85
|
+
expect(login.status).toBe(200);
|
|
86
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
87
|
+
};
|
|
88
|
+
alice = await mk(`tt_alice_${stamp}`, "member");
|
|
89
|
+
carol = await mk(`tt_carol_${stamp}`, "member");
|
|
90
|
+
vic = await mk(`tt_vic_${stamp}`, "viewer");
|
|
91
|
+
db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, alice.id]);
|
|
92
|
+
db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, carol.id]);
|
|
93
|
+
|
|
94
|
+
// 一张只有 legacy 标签的卡:它的标签没有节点行 —— 用来验证「拿在用标签当父」时补登记。
|
|
95
|
+
const card = await send(admin.token, "POST", "/api/requirements", { network_id: NET, name: "legacy", tags: ["legacy"] });
|
|
96
|
+
expect(card.status).toBe(201);
|
|
97
|
+
}, 30_000);
|
|
98
|
+
|
|
99
|
+
afterAll(() => {
|
|
100
|
+
try { hub?.stop?.(true); } catch {}
|
|
101
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("capability 与权限", () => {
|
|
105
|
+
test("列表 capabilities 带 tag_tree(旧客户端忽略)", async () => {
|
|
106
|
+
const r = await send(admin.token, "GET", `/api/requirements?network_id=${NET}`);
|
|
107
|
+
expect(r.body.capabilities).toContain("tag_tree");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("scoped / viewer / 节点令牌 403,且一行节点都不写", async () => {
|
|
111
|
+
for (const token of [alice.token, vic.token, nodeToken]) {
|
|
112
|
+
const r = await create(token, { name: "被拒" });
|
|
113
|
+
expect(r.status).toBe(403);
|
|
114
|
+
}
|
|
115
|
+
expect(nodeRows()).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("网络管理员与 task_access='all' 的老成员能建", async () => {
|
|
119
|
+
const r = await create(carol.token, { name: "carol建的" });
|
|
120
|
+
expect(r.status).toBe(201);
|
|
121
|
+
expect(r.body.tag).toEqual({ name: "carol建的", parent_id: null, depth: 1 });
|
|
122
|
+
const del = await op(admin.token, { op: "delete", tag: "carol建的" });
|
|
123
|
+
expect(del.status).toBe(200);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("建根标签与子标签", () => {
|
|
128
|
+
test("建根标签:201,parent_id=null,depth=1", async () => {
|
|
129
|
+
const r = await create(admin.token, { name: "需求" });
|
|
130
|
+
expect(r.status).toBe(201);
|
|
131
|
+
expect(r.body).toMatchObject({ ok: true, tag: { name: "需求", parent_id: null, depth: 1 }, parent_registered: false });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("0 任务的标签也能在 nodes 里读到(count=0,registered=true)", async () => {
|
|
135
|
+
const nodes = await nodesOf();
|
|
136
|
+
expect(nodes["需求"]).toEqual({ parent: null, depth: 1, count: 0, registered: true });
|
|
137
|
+
// tags(卡片上出现过的)保持旧语义:0 任务的标签不在里面
|
|
138
|
+
const t = await tagsOf(admin.token);
|
|
139
|
+
expect(t.body.tags).not.toContain("需求");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("建子标签:回读 parent_id / depth / count=0", async () => {
|
|
143
|
+
const r = await create(admin.token, { name: "前端", parent_id: "需求" });
|
|
144
|
+
expect(r.status).toBe(201);
|
|
145
|
+
expect(r.body.tag).toEqual({ name: "前端", parent_id: "需求", depth: 2 });
|
|
146
|
+
const nodes = await nodesOf();
|
|
147
|
+
expect(nodes["前端"]).toEqual({ parent: "需求", depth: 2, count: 0, registered: true });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("三代:第 3 层 depth=3;超过 MAX_TAG_DEPTH 的第 4 层子标签 400", async () => {
|
|
151
|
+
const l3 = await create(admin.token, { name: "按钮", parent_id: "前端" });
|
|
152
|
+
expect(l3.status).toBe(201);
|
|
153
|
+
expect(l3.body.tag.depth).toBe(3);
|
|
154
|
+
const l4 = await create(admin.token, { name: "主按钮", parent_id: "按钮" });
|
|
155
|
+
expect(l4.status).toBe(201);
|
|
156
|
+
expect(l4.body.tag.depth).toBe(4);
|
|
157
|
+
const l5 = await create(admin.token, { name: "太深了", parent_id: "主按钮" });
|
|
158
|
+
expect(l5.status).toBe(400);
|
|
159
|
+
expect(l5.body.error).toBe("tag_depth_exceeded");
|
|
160
|
+
expect(nodeRows().some((r: any) => r.name === "太深了")).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("重名 409:已登记的、卡片上在用的、有颜色行的都算重名;失败不留半条", async () => {
|
|
164
|
+
expect((await create(admin.token, { name: "需求" })).body.error).toBe("tag_exists");
|
|
165
|
+
expect((await create(admin.token, { name: "legacy" })).body.error).toBe("tag_exists");
|
|
166
|
+
// 只有颜色行、没有卡片也没有节点的名字也算存在(直接写库构造这种历史状态)
|
|
167
|
+
db.run("INSERT INTO network_tags (network_id, name, color, updated_at) VALUES (?1, ?2, ?3, ?4)", [NET, "有颜色的", "#112233", new Date().toISOString()]);
|
|
168
|
+
const colored = await create(admin.token, { name: "有颜色的" });
|
|
169
|
+
expect(colored.body.error).toBe("tag_exists");
|
|
170
|
+
const before = nodeRows();
|
|
171
|
+
expect(before.map((r: any) => r.name)).not.toContain("有颜色的");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("父不存在 404 tag_parent_not_found —— 不会静默建到根上", async () => {
|
|
175
|
+
const r = await create(admin.token, { name: "孤儿", parent_id: "不存在的父" });
|
|
176
|
+
expect(r.status).toBe(404);
|
|
177
|
+
expect(r.body.error).toBe("tag_parent_not_found");
|
|
178
|
+
expect(nodeRows().some((row: any) => row.name === "孤儿")).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("父只在卡片上出现过:补登记成顶层节点(parent_registered=true),子标签仍挂在它下面", async () => {
|
|
182
|
+
const r = await create(admin.token, { name: "legacy子", parent_id: "legacy" });
|
|
183
|
+
expect(r.status).toBe(201);
|
|
184
|
+
expect(r.body.parent_registered).toBe(true);
|
|
185
|
+
expect(r.body.tag).toEqual({ name: "legacy子", parent_id: "legacy", depth: 2 });
|
|
186
|
+
const nodes = await nodesOf();
|
|
187
|
+
expect(nodes["legacy"]).toMatchObject({ parent: null, depth: 1 });
|
|
188
|
+
expect(nodes["legacy子"]).toMatchObject({ parent: "legacy", depth: 2 });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("坏参数 400:空名 / 太长 / 控制字符 / parent_id 不是字符串", async () => {
|
|
192
|
+
expect((await create(admin.token, { name: "" })).body.error).toBe("invalid_tag");
|
|
193
|
+
expect((await create(admin.token, { name: "一二三四五六七八九十一二三四五六七八九十多" })).body.error).toBe("invalid_tag");
|
|
194
|
+
expect((await create(admin.token, { name: "换\n行" })).body.error).toBe("invalid_tag");
|
|
195
|
+
const bad = await create(admin.token, { name: "正常", parent_id: 42 });
|
|
196
|
+
expect(bad.status).toBe(400);
|
|
197
|
+
expect(bad.body.error).toBe("invalid_tag_parent_id");
|
|
198
|
+
expect((await create(admin.token, {})).body.error).toBe("invalid_tag");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("别的网络同名互不相干:NET2 里能再建「需求」,NET 的树不受影响", async () => {
|
|
202
|
+
const r = await create(other.token, { name: "需求" }, NET2);
|
|
203
|
+
expect(r.status).toBe(201);
|
|
204
|
+
expect(r.body.tag.depth).toBe(1);
|
|
205
|
+
expect((await nodesOf(admin.token, NET2))["需求"]).toMatchObject({ parent: null, depth: 1 });
|
|
206
|
+
expect((await nodesOf(admin.token, NET))["前端"]).toMatchObject({ parent: "需求", depth: 2 });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("受限成员的 nodes 只含他看得见的标签(登记过但没有可见卡的名字不外泄)", async () => {
|
|
210
|
+
const r = await tagsOf(alice.token);
|
|
211
|
+
expect(r.status).toBe(200);
|
|
212
|
+
expect(r.body.nodes).toEqual([]);
|
|
213
|
+
expect(r.body.can_manage).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("改名 / 合并 / 删除让层级跟着动", () => {
|
|
218
|
+
test("rename:节点与子节点的 parent 一起改,深度不变", async () => {
|
|
219
|
+
const r = await op(admin.token, { op: "rename", from: "需求", to: "需求池" });
|
|
220
|
+
expect(r.status).toBe(200);
|
|
221
|
+
const nodes = await nodesOf();
|
|
222
|
+
expect(nodes["需求池"]).toMatchObject({ parent: null, depth: 1 });
|
|
223
|
+
expect(nodes["前端"]).toMatchObject({ parent: "需求池", depth: 2 });
|
|
224
|
+
expect(nodes["按钮"]).toMatchObject({ parent: "前端", depth: 3 });
|
|
225
|
+
expect(nodes["需求"]).toBeUndefined();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("rename 到已存在的节点 = 合并:来源的子节点挂到目标,且不会自环", async () => {
|
|
229
|
+
await create(admin.token, { name: "临时" });
|
|
230
|
+
await create(admin.token, { name: "临时子", parent_id: "临时" });
|
|
231
|
+
const r = await op(admin.token, { op: "rename", from: "临时", to: "需求池" });
|
|
232
|
+
expect(r.status).toBe(200);
|
|
233
|
+
const nodes = await nodesOf();
|
|
234
|
+
expect(nodes["临时子"]).toMatchObject({ parent: "需求池", depth: 2 });
|
|
235
|
+
// 目标是来源的子孙时不能自环:把「前端」并到它的子节点「按钮」上
|
|
236
|
+
await op(admin.token, { op: "rename", from: "前端", to: "按钮" });
|
|
237
|
+
const after = await nodesOf();
|
|
238
|
+
expect(after["按钮"].parent).not.toBe("按钮");
|
|
239
|
+
expect(nodeRows().every((row: any) => row.name !== row.parent_name)).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("merge:来源节点的子节点挂到目标,来源节点删掉", async () => {
|
|
243
|
+
await create(admin.token, { name: "源A" });
|
|
244
|
+
await create(admin.token, { name: "源A子", parent_id: "源A" });
|
|
245
|
+
await create(admin.token, { name: "目标B" });
|
|
246
|
+
const r = await op(admin.token, { op: "merge", from: ["源A"], to: "目标B" });
|
|
247
|
+
expect(r.status).toBe(200);
|
|
248
|
+
expect(r.body.reparented).toBe(1);
|
|
249
|
+
const nodes = await nodesOf();
|
|
250
|
+
expect(nodes["源A"]).toBeUndefined();
|
|
251
|
+
expect(nodes["源A子"]).toMatchObject({ parent: "目标B", depth: 2 });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("delete:子节点挂到被删节点的父(没有父就成根),不丢层级", async () => {
|
|
255
|
+
const before = await nodesOf();
|
|
256
|
+
const r = await op(admin.token, { op: "delete", tag: "按钮" });
|
|
257
|
+
expect(r.status).toBe(200);
|
|
258
|
+
const nodes = await nodesOf();
|
|
259
|
+
// 「按钮」的父是「需求池」(rename 之后),所以「主按钮」挂到「需求池」
|
|
260
|
+
expect(nodes["主按钮"].parent).toBe(before["按钮"].parent);
|
|
261
|
+
expect(nodes["按钮"]).toBeUndefined();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("0 任务的节点也能上色,改名时颜色跟着走(#tag/ops 不再只认卡片)", async () => {
|
|
265
|
+
await create(admin.token, { name: "上色前" });
|
|
266
|
+
const c = await op(admin.token, { op: "color", tag: "上色前", color: "#ABCDEF" });
|
|
267
|
+
expect(c.status).toBe(200);
|
|
268
|
+
expect((await tagsOf(admin.token)).body.colors["上色前"]).toBe("#abcdef");
|
|
269
|
+
const r = await op(admin.token, { op: "rename", from: "上色前", to: "上色后" });
|
|
270
|
+
expect(r.status).toBe(200);
|
|
271
|
+
const after = await tagsOf(admin.token);
|
|
272
|
+
expect(after.body.colors["上色后"]).toBe("#abcdef");
|
|
273
|
+
expect(after.body.colors["上色前"]).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// 标签树(父子标签)。标签本身仍然只是卡片 tags_json 里的字符串(见 requirement-tags.ts);
|
|
2
|
+
// 这里只多存两件事:
|
|
3
|
+
// ① 谁挂在谁下面 —— network_tag_nodes(network_id, name, parent_name);
|
|
4
|
+
// ② 创建过但还没有任何卡片用的标签 —— 否则 0 任务的子标签在 GET /tags 里读不到(它只会数卡片)。
|
|
5
|
+
// 语义:网络内标签名就是 id(没有 tag_id),parent_name 指向父标签名;改名时父引用跟着改。
|
|
6
|
+
// 深度上限 MAX_TAG_DEPTH(含根)。父不存在 = 报错,不静默挂到根上。
|
|
7
|
+
import { tagName } from "./requirement-tags.js";
|
|
8
|
+
|
|
9
|
+
/** 最多 4 层(顶层是第 1 层)。比子需求(5 层)浅一层:侧栏要一屏放得下父子孙。 */
|
|
10
|
+
export const MAX_TAG_DEPTH = 4;
|
|
11
|
+
|
|
12
|
+
/** 客户端用这个 capability 判断 Hub 有没有「创建标签 / 父标签」;没有就灰掉入口,别调用。 */
|
|
13
|
+
export const TAG_TREE_CAPABILITY = "tag_tree";
|
|
14
|
+
|
|
15
|
+
export type TagNodeRow = { name: string; parent_name: string | null };
|
|
16
|
+
|
|
17
|
+
export type TagNodePublic = {
|
|
18
|
+
name: string;
|
|
19
|
+
parent_id: string | null;
|
|
20
|
+
depth: number;
|
|
21
|
+
/** 这张标签是不是「登记过」的节点(create 建的、或被 create 当父补登记的)。false = 只在卡片上出现过。 */
|
|
22
|
+
registered: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type CreateTagInput = { name: string; parent: string | null };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* POST /api/requirements/tags/create 的 body:
|
|
29
|
+
* { "name": "子标签", "parent_id": null | "父标签名" }
|
|
30
|
+
* parent_id 缺省/null = 建在根上;给了字符串就必须是网络里已存在的标签 —— 不存在回 404,
|
|
31
|
+
* **绝不**悄悄建到根上(客户端要预选父标签,静默 root fallback 会让 UI 显示的和实际存的不一致)。
|
|
32
|
+
*/
|
|
33
|
+
export function parseCreateTag(body: Record<string, unknown>): CreateTagInput | { error: string } {
|
|
34
|
+
const name = tagName(body.name);
|
|
35
|
+
if (!name) return { error: "invalid_tag" };
|
|
36
|
+
const rawParent = body.parent_id;
|
|
37
|
+
if (rawParent === undefined || rawParent === null) return { name, parent: null };
|
|
38
|
+
if (typeof rawParent !== "string") return { error: "invalid_tag_parent_id" };
|
|
39
|
+
const parent = tagName(rawParent);
|
|
40
|
+
if (!parent) return { error: "invalid_tag_parent_id" };
|
|
41
|
+
if (parent === name) return { error: "tag_parent_cycle" };
|
|
42
|
+
return { name, parent };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 从 name 往上数几层(顶层 = 1)。链上有环(历史脏数据)返回 Infinity,调用方据此拒绝。 */
|
|
46
|
+
export function tagDepth(name: string, parentOf: Map<string, string | null>): number {
|
|
47
|
+
let depth = 1;
|
|
48
|
+
let cur = parentOf.get(name) ?? null;
|
|
49
|
+
const seen = new Set<string>([name]);
|
|
50
|
+
while (cur) {
|
|
51
|
+
if (seen.has(cur)) return Number.POSITIVE_INFINITY;
|
|
52
|
+
seen.add(cur);
|
|
53
|
+
depth++;
|
|
54
|
+
cur = parentOf.get(cur) ?? null;
|
|
55
|
+
}
|
|
56
|
+
return depth;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** child 是不是 ancestor 的子孙(用于改名时的防环判断)。 */
|
|
60
|
+
export function isDescendant(child: string, ancestor: string, parentOf: Map<string, string | null>): boolean {
|
|
61
|
+
let cur: string | null = parentOf.get(child) ?? null;
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
while (cur) {
|
|
64
|
+
if (cur === ancestor) return true;
|
|
65
|
+
if (seen.has(cur)) return false;
|
|
66
|
+
seen.add(cur);
|
|
67
|
+
cur = parentOf.get(cur) ?? null;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* GET /api/requirements/tags 的 nodes:
|
|
74
|
+
* 登记过的节点 ∪ 调用者看得见的在用标签(后者 parent_id = null、registered = false,
|
|
75
|
+
* 老 Hub 上就是一堆平标签,升级后不会突然消失)。
|
|
76
|
+
*/
|
|
77
|
+
export function buildTagNodes(registered: TagNodeRow[], inUse: string[]): TagNodePublic[] {
|
|
78
|
+
const parentOf = new Map<string, string | null>();
|
|
79
|
+
const isRegistered = new Set<string>();
|
|
80
|
+
for (const row of registered) {
|
|
81
|
+
parentOf.set(row.name, row.parent_name ?? null);
|
|
82
|
+
isRegistered.add(row.name);
|
|
83
|
+
}
|
|
84
|
+
for (const name of inUse) if (!parentOf.has(name)) parentOf.set(name, null);
|
|
85
|
+
const nodes: TagNodePublic[] = [];
|
|
86
|
+
for (const [name, parent] of parentOf) {
|
|
87
|
+
const depth = tagDepth(name, parentOf);
|
|
88
|
+
nodes.push({
|
|
89
|
+
name,
|
|
90
|
+
parent_id: parent,
|
|
91
|
+
// 环/脏数据不给客户端 NaN/Infinity:按顶层展示,写接口仍然会拒绝往里挂。
|
|
92
|
+
depth: Number.isFinite(depth) ? depth : 1,
|
|
93
|
+
registered: isRegistered.has(name),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return nodes.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
97
|
+
}
|
|
@@ -34,6 +34,14 @@ const ENTRIES: Record<string, Entry> = {
|
|
|
34
34
|
not_found: { message: 'No such task, project or route.', hint: 'Check the id; requirements_list / projects_list return the ids you can use.' },
|
|
35
35
|
project_not_found: { field: 'id', message: 'No project with that id in this network.', hint: 'Call projects_list to get project ids (proj_…).' },
|
|
36
36
|
tag_not_found: { field: 'tag', message: 'No task in this network carries that tag.', hint: 'Tags are free text on tasks; list the ones in use from requirements_list (view=summary shows each task’s tags).' },
|
|
37
|
+
// 标签树(POST /api/requirements/tags/create):父标签是**标签名**不是 req_… id,所以用自己的错误码,
|
|
38
|
+
// 不借用任务父需求的 parent_not_found / invalid_parent_id(那两句文案会让调用方去查 req id)。
|
|
39
|
+
tag_exists: { field: 'name', message: 'This network already has a tag with that name (on a task, with a colour, or as a registered node).', hint: 'Pick another name, or rename / merge the existing tag with POST /api/requirements/tags/ops.' },
|
|
40
|
+
tag_parent_not_found: { field: 'parent_id', message: 'No tag named that in this network.', hint: 'Create the parent first (parent_id: null), or read GET /api/requirements/tags → nodes for the names that exist.' },
|
|
41
|
+
invalid_tag_parent_id: { field: 'parent_id', message: 'parent_id must be a tag name in this network, or null for the root.', hint: 'Tag names are 1–20 characters; read GET /api/requirements/tags → nodes for the names that exist.' },
|
|
42
|
+
tag_parent_cycle: { field: 'parent_id', message: 'A tag cannot be its own parent.', hint: 'Pass a different parent, or null for the root.' },
|
|
43
|
+
tag_depth_exceeded: { field: 'parent_id', message: 'Tags nest at most 4 levels (the root counts as level 1).', hint: 'Attach the tag to a higher-level tag.' },
|
|
44
|
+
invalid_tag: { field: 'name', message: 'Tag names are 1–20 characters after trimming, with no control characters.', hint: 'Shorten the name, or remove line breaks.' },
|
|
37
45
|
checklist_item_not_found: { field: 'item_id', message: 'This task has no checklist item with that id.', hint: 'Item ids (ck_…) are in requirements_get → checklist[].id.' },
|
|
38
46
|
// ── 权限 ──
|
|
39
47
|
user_token_required: { message: 'This operation is for people only: a node token can read projects and read / create / update tasks, but cannot manage projects or delete tasks.', hint: 'Use an existing project (projects_list), or ask a person to create / change the project in the app (任务 → 管理项目).' },
|
|
@@ -373,7 +373,7 @@ describe("requirements stay on the hub", () => {
|
|
|
373
373
|
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "" }) })).body.requirement.due).toBe("");
|
|
374
374
|
const listed = await api(ownerToken, "/api/requirements");
|
|
375
375
|
expect(listed.body.capabilities).toContain("due_datetime");
|
|
376
|
-
expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_status_snapshot", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"]);
|
|
376
|
+
expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_status_snapshot", "tag_ops", "tag_tree", "list_summary", "changes", "events", "last_event", "column_abandoned"]);
|
|
377
377
|
});
|
|
378
378
|
|
|
379
379
|
test("sub-requirements: parent_id in the same network, no cycles, ≤ 5 levels, child counts, filters, delete detaches children", async () => {
|
|
@@ -59,6 +59,27 @@ export function ensureNetworkTags(database: DbAdapter): void {
|
|
|
59
59
|
`);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* 标签树(父子标签)。标签名就是 id,parent_name 指向同网络的父标签名;没有 parent 行 = 顶层。
|
|
64
|
+
* 只存「登记过的节点」:卡片上出现但没登记过的标签(老数据)仍然是平标签,不改写任何 cards_json。
|
|
65
|
+
* 建标签、改名 / 合并 / 删除时维护这里(见 requirements.ts 的 handleTagCreate / handleTagOp)。
|
|
66
|
+
* IF NOT EXISTS:重复执行无副作用。
|
|
67
|
+
*/
|
|
68
|
+
export function ensureNetworkTagNodes(database: DbAdapter): void {
|
|
69
|
+
database.exec(`
|
|
70
|
+
CREATE TABLE IF NOT EXISTS network_tag_nodes (
|
|
71
|
+
network_id TEXT NOT NULL,
|
|
72
|
+
name TEXT NOT NULL,
|
|
73
|
+
parent_name TEXT,
|
|
74
|
+
created_at TEXT NOT NULL,
|
|
75
|
+
updated_at TEXT NOT NULL,
|
|
76
|
+
created_by_json TEXT,
|
|
77
|
+
PRIMARY KEY (network_id, name)
|
|
78
|
+
);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS idx_network_tag_nodes_parent ON network_tag_nodes(network_id, parent_name);
|
|
80
|
+
`);
|
|
81
|
+
}
|
|
82
|
+
|
|
62
83
|
/**
|
|
63
84
|
* 删掉的卡留一条墓碑,给 GET /api/requirements?changes=1 报「这些 id 没了」(按 updated_since 增量同步的客户端
|
|
64
85
|
* 否则永远不知道一张卡被删了)。连同判断可见性要用的列一起存:受限成员只收到他本来看得见的卡的删除。
|
package/src/requirements.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { db, logAudit } from "./db.js";
|
|
|
8
8
|
import { resolveNodeCaller } from "./create-node.js";
|
|
9
9
|
import { markGzipReusable } from "./http-gzip.js";
|
|
10
10
|
import { applyTagOp, normalizeTags, parseTagOp, storedTags, type TagOp } from "./requirement-tags.js";
|
|
11
|
+
import { buildTagNodes, MAX_TAG_DEPTH, parseCreateTag, TAG_TREE_CAPABILITY, tagDepth, type TagNodeRow } from "./requirement-tag-tree.js";
|
|
11
12
|
import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-stats.js";
|
|
12
13
|
import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
13
14
|
import { isAgentRestricted, restrictedNetworkIds, visibleAgents } from "./agent-access.js";
|
|
@@ -20,7 +21,7 @@ import { notifyLeaderDeleted, notifyParticipantChange } from "./requirement-noti
|
|
|
20
21
|
import { departmentCardsFor, departmentSubtree } from "./department-heads.js";
|
|
21
22
|
import { dueReminderTimezone, dueWithinSql, ensureDueReminders, overdueSql } from "./requirement-due-reminders.js";
|
|
22
23
|
import { diffRequirement, ensureRequirementEvents, eventPublic, recordRequirementEvents, type EventRow } from "./requirement-events.js";
|
|
23
|
-
import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementColumnCheck, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
|
|
24
|
+
import { ensureNetworkTagNodes, ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementColumnCheck, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
|
|
24
25
|
|
|
25
26
|
// 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
|
|
26
27
|
// 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
|
|
@@ -32,6 +33,7 @@ ensureRequirementIndexes(db);
|
|
|
32
33
|
ensureRequirementSeq(db);
|
|
33
34
|
ensureRequirementCompletedAt(db);
|
|
34
35
|
ensureNetworkTags(db);
|
|
36
|
+
ensureNetworkTagNodes(db);
|
|
35
37
|
ensureRequirementTombstones(db);
|
|
36
38
|
ensureRequirementEvents(db);
|
|
37
39
|
ensureDueReminders(db);
|
|
@@ -474,7 +476,9 @@ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
|
|
|
474
476
|
);
|
|
475
477
|
const colorOf = (name: string) => db.get<{ color: string }>("SELECT color FROM network_tags WHERE network_id = ?1 AND name = ?2", networkId, name)?.color ?? null;
|
|
476
478
|
const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
|
|
477
|
-
|
|
479
|
+
// 「存在」= 有卡片在用 / 有颜色行 / 是登记过的树节点。最后一种让 0 任务的标签也能改名、合并、删除、上色。
|
|
480
|
+
const nodeExists = (name: string) => !!db.get<{ x: number }>("SELECT 1 AS x FROM network_tag_nodes WHERE network_id = ?1 AND name = ?2", networkId, name);
|
|
481
|
+
const known = cards.some(card => storedTags(card.tags_json).some(tag => sources.includes(tag))) || sources.some(tag => colorOf(tag) !== null || nodeExists(tag));
|
|
478
482
|
if (!known) return null;
|
|
479
483
|
if (op.op === "color") {
|
|
480
484
|
if (op.color === null) db.run("DELETE FROM network_tags WHERE network_id = ?1 AND name = ?2", [networkId, op.tag]);
|
|
@@ -490,11 +494,12 @@ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
|
|
|
490
494
|
affected++;
|
|
491
495
|
}
|
|
492
496
|
moveTagColors(networkId, op, colorOf, now);
|
|
493
|
-
|
|
497
|
+
const reparented = maintainTagTree(networkId, op, now);
|
|
498
|
+
return { affected, reparented };
|
|
494
499
|
});
|
|
495
500
|
if (!result) return jsonError("tag_not_found", 404);
|
|
496
501
|
if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, `requirement_tag_${op.op}`, "requirement_tag", op.op === "rename" || op.op === "merge" ? op.to : op.tag, JSON.stringify({ ...op, affected: result.affected }).slice(0, 1000), undefined, networkId);
|
|
497
|
-
return Response.json({ ok: true, op: op.op, affected: result.affected });
|
|
502
|
+
return Response.json({ ok: true, op: op.op, affected: result.affected, reparented: result.reparented });
|
|
498
503
|
}
|
|
499
504
|
|
|
500
505
|
/** 改名 / 合并:目标没有颜色就继承第一个有颜色的来源;来源的颜色行删掉。删除:删颜色行。 */
|
|
@@ -508,6 +513,115 @@ function moveTagColors(networkId: string, op: TagOp, colorOf: (name: string) =>
|
|
|
508
513
|
for (const tag of sources) db.run("DELETE FROM network_tags WHERE network_id = ?1 AND name = ?2", [networkId, tag]);
|
|
509
514
|
}
|
|
510
515
|
|
|
516
|
+
/**
|
|
517
|
+
* 标签改名 / 合并 / 删除时让树跟着动(与卡片改写同一个事务):
|
|
518
|
+
* rename —— 节点改名,子节点的 parent_name 一起改;目标已有节点时按合并处理(并把「目标自己是来源的子节点」这种情况挂到来源的父上,避免自环)。
|
|
519
|
+
* merge —— 来源节点的子节点挂到目标,来源节点删掉。
|
|
520
|
+
* delete —— 被删节点的子节点挂到它的父(没有父就成根);不删子节点、不丢层级。
|
|
521
|
+
* 返回被改挂的节点数(客户端能报「N 个子标签已上移」)。
|
|
522
|
+
*/
|
|
523
|
+
function maintainTagTree(networkId: string, op: TagOp, now: string): number {
|
|
524
|
+
if (op.op === "color") return 0;
|
|
525
|
+
const rows = db.all<TagNodeRow>("SELECT name, parent_name FROM network_tag_nodes WHERE network_id = ?1", networkId);
|
|
526
|
+
if (!rows.length) return 0;
|
|
527
|
+
const parentOf = new Map(rows.map(row => [row.name, row.parent_name ?? null]));
|
|
528
|
+
if (op.op === "rename") {
|
|
529
|
+
if (!parentOf.has(op.from)) return 0;
|
|
530
|
+
const fromParent = parentOf.get(op.from) ?? null;
|
|
531
|
+
if (!parentOf.has(op.to)) {
|
|
532
|
+
db.run("UPDATE network_tag_nodes SET name = ?1, updated_at = ?2 WHERE network_id = ?3 AND name = ?4", [op.to, now, networkId, op.from]);
|
|
533
|
+
const moved = db.run("UPDATE network_tag_nodes SET parent_name = ?1, updated_at = ?2 WHERE network_id = ?3 AND parent_name = ?4", [op.to, now, networkId, op.from]);
|
|
534
|
+
return moved.changes ?? 0;
|
|
535
|
+
}
|
|
536
|
+
// 目标已有节点 —— 合并。目标若正好是来源的子孙,直接挂过去会自环,改挂到来源的父。
|
|
537
|
+
let moved = 0;
|
|
538
|
+
for (const row of rows) {
|
|
539
|
+
if (row.parent_name !== op.from) continue;
|
|
540
|
+
const nextParent = row.name === op.to ? fromParent : op.to;
|
|
541
|
+
db.run("UPDATE network_tag_nodes SET parent_name = ?1, updated_at = ?2 WHERE network_id = ?3 AND name = ?4", [nextParent, now, networkId, row.name]);
|
|
542
|
+
moved++;
|
|
543
|
+
}
|
|
544
|
+
db.run("DELETE FROM network_tag_nodes WHERE network_id = ?1 AND name = ?2", [networkId, op.from]);
|
|
545
|
+
return moved;
|
|
546
|
+
}
|
|
547
|
+
if (op.op === "merge") {
|
|
548
|
+
let moved = 0;
|
|
549
|
+
for (const source of op.from) {
|
|
550
|
+
if (!parentOf.has(source)) continue;
|
|
551
|
+
const children = rows.filter(row => row.parent_name === source);
|
|
552
|
+
const res = db.run("UPDATE network_tag_nodes SET parent_name = ?1, updated_at = ?2 WHERE network_id = ?3 AND parent_name = ?4", [op.to, now, networkId, source]);
|
|
553
|
+
moved += res.changes ?? children.length;
|
|
554
|
+
db.run("DELETE FROM network_tag_nodes WHERE network_id = ?1 AND name = ?2", [networkId, source]);
|
|
555
|
+
}
|
|
556
|
+
return moved;
|
|
557
|
+
}
|
|
558
|
+
// delete
|
|
559
|
+
const row = parentOf.has(op.tag) ? rows.find(item => item.name === op.tag)! : null;
|
|
560
|
+
if (!row) return 0;
|
|
561
|
+
const res = db.run("UPDATE network_tag_nodes SET parent_name = ?1, updated_at = ?2 WHERE network_id = ?3 AND parent_name = ?4", [row.parent_name ?? null, now, networkId, op.tag]);
|
|
562
|
+
db.run("DELETE FROM network_tag_nodes WHERE network_id = ?1 AND name = ?2", [networkId, op.tag]);
|
|
563
|
+
return res.changes ?? 0;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** 网络里所有卡片上出现过的标签(**不分可见范围**)。写操作按整个网络判重,否则同名标签会被拆成两半。 */
|
|
567
|
+
function networkTagNames(networkId: string): Set<string> {
|
|
568
|
+
const names = new Set<string>();
|
|
569
|
+
for (const row of db.all<{ tags_json: string | null }>(
|
|
570
|
+
"SELECT tags_json FROM requirements WHERE network_id = ?1 AND tags_json IS NOT NULL AND tags_json <> '[]'", networkId,
|
|
571
|
+
)) {
|
|
572
|
+
for (const tag of storedTags(row.tags_json)) names.add(tag);
|
|
573
|
+
}
|
|
574
|
+
return names;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
type CreateTagOutcome =
|
|
578
|
+
| { error: "tag_exists" | "tag_parent_not_found" | "tag_depth_exceeded" }
|
|
579
|
+
| { name: string; parent: string | null; depth: number; parentRegistered: boolean };
|
|
580
|
+
|
|
581
|
+
/** POST /api/requirements/tags/create —— 建标签(可带父)。原子、整网络判重、父不存在就报错。 */
|
|
582
|
+
async function handleTagCreate(ctx: RequirementsRequestContext): Promise<Response> {
|
|
583
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
584
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
585
|
+
// 与改名 / 合并 / 删除同一把锁:整网写,只给不受任务范围限制的人。
|
|
586
|
+
if (!canManageTags(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
587
|
+
let body: Record<string, unknown>;
|
|
588
|
+
try { body = await bodyObject(ctx.req); } catch { return jsonError("invalid_json", 400); }
|
|
589
|
+
const input = parseCreateTag(body);
|
|
590
|
+
if ("error" in input) return jsonError(input.error, 400);
|
|
591
|
+
const now = new Date().toISOString();
|
|
592
|
+
const actor = JSON.stringify(actorOf(ctx));
|
|
593
|
+
const result = db.transaction<CreateTagOutcome>(() => {
|
|
594
|
+
const nodes = db.all<TagNodeRow>("SELECT name, parent_name FROM network_tag_nodes WHERE network_id = ?1", networkId);
|
|
595
|
+
const parentOf = new Map(nodes.map(row => [row.name, row.parent_name ?? null]));
|
|
596
|
+
const used = networkTagNames(networkId);
|
|
597
|
+
const hasColor = (name: string) => !!db.get<{ x: number }>("SELECT 1 AS x FROM network_tags WHERE network_id = ?1 AND name = ?2", networkId, name);
|
|
598
|
+
const exists = (name: string) => parentOf.has(name) || used.has(name) || hasColor(name);
|
|
599
|
+
// 重名 = 409:不「复用」已有标签(复用会让 UI 以为新建成功,实际改了别的分支的标签)。
|
|
600
|
+
if (exists(input.name)) return { error: "tag_exists" };
|
|
601
|
+
let parentRegistered = false;
|
|
602
|
+
if (input.parent) {
|
|
603
|
+
// 父不存在 = 404。**不**把子标签建到根上:客户端预选了父,存成根会让回读的层级和表单不一致。
|
|
604
|
+
if (!exists(input.parent)) return { error: "tag_parent_not_found" };
|
|
605
|
+
if (!parentOf.has(input.parent)) {
|
|
606
|
+
// 父此前只在卡片上出现过(老数据):登记成顶层节点。父本来就没有父,这不是把子标签降级到根。
|
|
607
|
+
db.run("INSERT INTO network_tag_nodes (network_id, name, parent_name, created_at, updated_at, created_by_json) VALUES (?1, ?2, NULL, ?3, ?3, ?4)", [networkId, input.parent, now, actor]);
|
|
608
|
+
parentOf.set(input.parent, null);
|
|
609
|
+
parentRegistered = true;
|
|
610
|
+
}
|
|
611
|
+
if (tagDepth(input.parent, parentOf) + 1 > MAX_TAG_DEPTH) return { error: "tag_depth_exceeded" };
|
|
612
|
+
}
|
|
613
|
+
db.run("INSERT INTO network_tag_nodes (network_id, name, parent_name, created_at, updated_at, created_by_json) VALUES (?1, ?2, ?3, ?4, ?4, ?5)", [networkId, input.name, input.parent, now, actor]);
|
|
614
|
+
parentOf.set(input.name, input.parent);
|
|
615
|
+
return { name: input.name, parent: input.parent, depth: tagDepth(input.name, parentOf), parentRegistered };
|
|
616
|
+
});
|
|
617
|
+
if ("error" in result) {
|
|
618
|
+
const status = result.error === "tag_exists" ? 409 : result.error === "tag_parent_not_found" ? 404 : 400;
|
|
619
|
+
return jsonError(result.error, status);
|
|
620
|
+
}
|
|
621
|
+
if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, "requirement_tag_create", "requirement_tag", result.name, JSON.stringify({ parent_id: result.parent, depth: result.depth }).slice(0, 1000), undefined, networkId);
|
|
622
|
+
return Response.json({ ok: true, tag: { name: result.name, parent_id: result.parent, depth: result.depth }, parent_registered: result.parentRegistered }, { status: 201 });
|
|
623
|
+
}
|
|
624
|
+
|
|
511
625
|
function toPublic(row: Row) {
|
|
512
626
|
return {
|
|
513
627
|
owner: row.owner_json ? JSON.parse(row.owner_json) : null,
|
|
@@ -726,7 +840,7 @@ export const COMMENT_MAX_CHARS = 4000;
|
|
|
726
840
|
const NODE_TOKEN_OPERATIONS: ReadonlySet<string> = new Set<string>(["read", "create", "patch", "checklist_item", "comment", "upsert", "projects_read"]);
|
|
727
841
|
function operationOf(req: Request, url: URL): string {
|
|
728
842
|
if (/^\/api\/requirements\/projects(\/|$)/.test(url.pathname)) return req.method === "GET" ? "projects_read" : "projects_write";
|
|
729
|
-
if (url.pathname === "/api/requirements/tags/ops") return "tags_write";
|
|
843
|
+
if (url.pathname === "/api/requirements/tags/ops" || url.pathname === "/api/requirements/tags/create") return "tags_write";
|
|
730
844
|
if (req.method === "GET") return "read";
|
|
731
845
|
if (/^\/api\/requirements\/[^/]+\/checklist\/[^/]+$/.test(url.pathname)) return "checklist_item";
|
|
732
846
|
if (/^\/api\/requirements\/[^/]+\/comments$/.test(url.pathname)) return "comment";
|
|
@@ -770,7 +884,7 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
|
|
|
770
884
|
// list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
|
|
771
885
|
// (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
|
|
772
886
|
// events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
|
|
773
|
-
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_status_snapshot", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
|
|
887
|
+
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_status_snapshot", "tag_ops", TAG_TREE_CAPABILITY, "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
|
|
774
888
|
|
|
775
889
|
// ── 子需求 ──
|
|
776
890
|
// parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
|
|
@@ -1559,15 +1673,22 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1559
1673
|
const tagSql = addTaskVisibilityScope("SELECT tags_json FROM requirements WHERE network_id=?1", tagParams, taskCaller(ctx));
|
|
1560
1674
|
const rows = db.all<{ tags_json: string | null }>(tagSql, ...tagParams);
|
|
1561
1675
|
// counts:调用者看得见的卡里(含归档)每个标签用了几张;colors:只回这些标签的颜色(看不见的卡上的标签不外泄)。
|
|
1562
|
-
// can_manage:这个调用者能不能用 /tags/ops。旧 App 只读 tags,多出的字段忽略。
|
|
1676
|
+
// can_manage:这个调用者能不能用 /tags/ops /tags/create。旧 App 只读 tags,多出的字段忽略。
|
|
1563
1677
|
const counts: Record<string, number> = {};
|
|
1564
1678
|
for (const row of rows) for (const tag of storedTags(row.tags_json)) counts[tag] = (counts[tag] ?? 0) + 1;
|
|
1565
1679
|
const tags = Object.keys(counts).sort();
|
|
1680
|
+
const manageable = canManageTags(ctx, networkId);
|
|
1681
|
+
// nodes:标签树。登记过的节点(create 建的,含 0 任务)∪ 调用者看得见的在用标签。
|
|
1682
|
+
// 受限成员只拿得到「看得见的名字」:登记过但一张可见卡都没有的标签不外泄(与 counts / colors 同一口径)。
|
|
1683
|
+
const registeredRows = db.all<TagNodeRow>("SELECT name, parent_name FROM network_tag_nodes WHERE network_id = ?1", networkId);
|
|
1684
|
+
const visibleRegistered = manageable ? registeredRows : registeredRows.filter(row => counts[row.name] > 0);
|
|
1685
|
+
const nodeNames = new Set([...registeredRows.map(row => row.name), ...tags]);
|
|
1566
1686
|
const colors: Record<string, string> = {};
|
|
1567
1687
|
for (const row of db.all<{ name: string; color: string }>("SELECT name, color FROM network_tags WHERE network_id = ?1", networkId)) {
|
|
1568
|
-
if (counts[row.name]) colors[row.name] = row.color;
|
|
1688
|
+
if (counts[row.name] || (manageable && nodeNames.has(row.name))) colors[row.name] = row.color;
|
|
1569
1689
|
}
|
|
1570
|
-
|
|
1690
|
+
const nodes = buildTagNodes(visibleRegistered, tags).map(node => ({ ...node, count: counts[node.name] ?? 0, color: colors[node.name] ?? null }));
|
|
1691
|
+
return Response.json({ ok: true, networkId, tags, counts, colors, nodes, can_manage: manageable });
|
|
1571
1692
|
}
|
|
1572
1693
|
|
|
1573
1694
|
if (url.pathname === "/api/requirements/tags/ops") {
|
|
@@ -1575,6 +1696,12 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1575
1696
|
return handleTagOp(ctx);
|
|
1576
1697
|
}
|
|
1577
1698
|
|
|
1699
|
+
// 建标签(可带 parent_id)。原子:节点 + 审计在一个事务里;父不存在 404,绝不安静建到根上。
|
|
1700
|
+
if (url.pathname === "/api/requirements/tags/create") {
|
|
1701
|
+
if (req.method !== "POST") return jsonError("not_found", 404);
|
|
1702
|
+
return handleTagCreate(ctx);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1578
1705
|
// 仪表盘聚合(requirements-stats.ts):一个网络、调用者看得见的卡(与列表同一个可见范围),含归档的卡 ——
|
|
1579
1706
|
// 完成的卡常被归档,不算它们「今天完成了多少」就会偏少。
|
|
1580
1707
|
if (url.pathname === "/api/requirements/stats" && req.method === "GET") {
|