@sleep2agi/commhub-server 0.9.0-preview.66 → 0.9.0-preview.68
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/agent-access.ts +267 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/auth.ts +152 -7
- package/src/create-node-workdir.test.ts +2 -1
- package/src/db.ts +35 -1
- package/src/external-schedule-edits-http.test.ts +3 -2
- package/src/file-download-authz.test.ts +2 -1
- package/src/file-network-scope.test.ts +6 -5
- package/src/health-redaction.test.ts +3 -2
- package/src/human-dm.ts +149 -0
- package/src/network-scope.ts +126 -3
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +2 -1
- package/src/push.ts +43 -2
- package/src/rename.ts +7 -2
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-http.test.ts +278 -12
- package/src/requirements-mcp.test.ts +135 -0
- package/src/requirements-migrate.ts +7 -0
- package/src/requirements-upgrade-e2e.test.ts +1 -1
- package/src/requirements-upgrade-migration.test.ts +9 -0
- package/src/requirements.ts +479 -137
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/restricted-files.ts +73 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +92 -1
- package/src/scheduled-tasks.ts +57 -10
- package/src/server.ts +345 -41
- package/src/tools.ts +202 -31
package/src/requirements.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
// 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
|
|
2
2
|
// 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
|
|
3
3
|
import { db } from "./db.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { normalizeTags, storedTags } from "./requirement-tags.js";
|
|
5
|
+
import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
6
|
+
import { isAgentRestricted, visibleAgents } from "./agent-access.js";
|
|
7
|
+
import { ensureRequirementIndexes, ensureRequirementProjects, migrateRequirementAgentOwners } from "./requirements-migrate.js";
|
|
6
8
|
|
|
7
9
|
// 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
|
|
8
10
|
// 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
|
|
9
11
|
migrateRequirementAgentOwners(db);
|
|
10
12
|
ensureRequirementProjects(db);
|
|
13
|
+
ensureRequirementIndexes(db);
|
|
11
14
|
|
|
12
|
-
type RequestAuth = { userId: string; networkId: string | null; username: string } | null;
|
|
15
|
+
type RequestAuth = { userId: string; networkId: string | null; username: string; tokenId?: string | null; tokenName?: string | null } | null;
|
|
13
16
|
|
|
14
17
|
export type RequirementsRequestContext = {
|
|
15
18
|
req: Request;
|
|
@@ -30,6 +33,16 @@ type Row = {
|
|
|
30
33
|
description: string | null;
|
|
31
34
|
checklist_json: string | null;
|
|
32
35
|
project_id: string | null;
|
|
36
|
+
external_ref: string | null;
|
|
37
|
+
external_url: string | null;
|
|
38
|
+
archived: number | null;
|
|
39
|
+
created_by: string | null;
|
|
40
|
+
created_by_json: string | null;
|
|
41
|
+
updated_by_json: string | null;
|
|
42
|
+
updated_at: string | null;
|
|
43
|
+
parent_id: string | null;
|
|
44
|
+
children_total: number | null;
|
|
45
|
+
children_done: number | null;
|
|
33
46
|
participants_json: string;
|
|
34
47
|
requirement_id: string;
|
|
35
48
|
network_id: string;
|
|
@@ -39,6 +52,7 @@ type Row = {
|
|
|
39
52
|
due_on: string | null;
|
|
40
53
|
assignee: string | null;
|
|
41
54
|
issues_json: string | null;
|
|
55
|
+
tags_json: string | null;
|
|
42
56
|
created_at: string;
|
|
43
57
|
};
|
|
44
58
|
|
|
@@ -271,7 +285,19 @@ function toPublic(row: Row) {
|
|
|
271
285
|
due: row.due_on || "",
|
|
272
286
|
column: row.column_name,
|
|
273
287
|
createdAt: row.created_at,
|
|
288
|
+
updatedAt: row.updated_at || row.created_at,
|
|
274
289
|
issues: storedIssues(row.issues_json),
|
|
290
|
+
tags: storedTags(row.tags_json),
|
|
291
|
+
// 同步用的外部引用(如 github:owner/repo#123)和链接;归档的卡默认不在列表里(include_archived=1 才有)。
|
|
292
|
+
external_ref: row.external_ref || null,
|
|
293
|
+
external_url: row.external_url || null,
|
|
294
|
+
archived: !!row.archived,
|
|
295
|
+
// 谁建的 / 谁最后改的:{kind:"user"|"node", id}。旧卡只有 created_by(用户 id)。
|
|
296
|
+
created_by: row.created_by_json ? JSON.parse(row.created_by_json) : row.created_by ? { kind: "user", id: row.created_by } : null,
|
|
297
|
+
updated_by: row.updated_by_json ? JSON.parse(row.updated_by_json) : null,
|
|
298
|
+
// 子需求:parent_id(可空)和父卡上的子需求进度(未归档的子需求数 / 其中完成的)。
|
|
299
|
+
parent_id: row.parent_id || null,
|
|
300
|
+
children: { total: Number(row.children_total ?? 0), done: Number(row.children_done ?? 0) },
|
|
275
301
|
};
|
|
276
302
|
}
|
|
277
303
|
|
|
@@ -288,88 +314,473 @@ function writeNetwork(body: Record<string, unknown>, ctx: RequirementsRequestCon
|
|
|
288
314
|
}
|
|
289
315
|
|
|
290
316
|
function canWrite(ctx: RequirementsRequestContext, networkId: string | null): boolean {
|
|
291
|
-
|
|
317
|
+
// 节点令牌只能写它绑定的那个网络(scope 已强制;这里再核一次,防止别的入口绕过 scope)。
|
|
318
|
+
if (ctx.isNodeToken && (!ctx.auth?.networkId || ctx.auth.networkId !== networkId)) return false;
|
|
319
|
+
if (ctx.isNodeToken) return canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
|
|
320
|
+
// 需求看板是人类协作面:受限成员(只看授权 Agent 的成员)照样能读写卡片,
|
|
321
|
+
// 只是卡片上没授权给他的 Agent 引用会被隐去、也不能被他指派(见 hiddenNodeFilter)。
|
|
322
|
+
return canRestWriteNetworkAsHuman(ctx.auth, networkId, ctx.isAdmin);
|
|
292
323
|
}
|
|
293
324
|
|
|
294
|
-
|
|
325
|
+
// ── 多用户 Agent 权限 ──
|
|
326
|
+
// 受限成员看需求卡时:agent_owner / 参与人 / created_by / updated_by 里没授权给他的节点一律隐去;
|
|
327
|
+
// 他写卡时也不能引用这些节点。返回 null = 这个调用者在这个网络里不受限。
|
|
328
|
+
type HiddenNode = ((nodeId: string) => boolean) | null;
|
|
329
|
+
function hiddenNodeFilter(ctx: RequirementsRequestContext, networkId: string): HiddenNode {
|
|
330
|
+
if (!ctx.auth || ctx.isNodeToken || ctx.isAdmin) return null;
|
|
331
|
+
if (!isAgentRestricted(ctx.auth.userId, networkId)) return null;
|
|
332
|
+
const visible = new Set(visibleAgents(ctx.auth.userId, networkId).nodeIds);
|
|
333
|
+
return (nodeId: string) => !visible.has(nodeId);
|
|
334
|
+
}
|
|
335
|
+
function isHiddenRef(ref: unknown, hidden: HiddenNode): boolean {
|
|
336
|
+
if (!hidden || !ref || typeof ref !== "object") return false;
|
|
337
|
+
const r = ref as { kind?: unknown; id?: unknown };
|
|
338
|
+
return r.kind === "node" && typeof r.id === "string" && hidden(r.id);
|
|
339
|
+
}
|
|
340
|
+
function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
|
|
341
|
+
const pub = toPublic(row);
|
|
342
|
+
const hidden = hiddenNodeFilter(ctx, row.network_id);
|
|
343
|
+
if (!hidden) return pub;
|
|
344
|
+
return {
|
|
345
|
+
...pub,
|
|
346
|
+
owner: isHiddenRef(pub.owner, hidden) ? null : pub.owner,
|
|
347
|
+
agent_owner: isHiddenRef(pub.agent_owner, hidden) ? null : pub.agent_owner,
|
|
348
|
+
participants: Array.isArray(pub.participants) ? pub.participants.filter((ref: unknown) => !isHiddenRef(ref, hidden)) : pub.participants,
|
|
349
|
+
created_by: isHiddenRef(pub.created_by, hidden) ? null : pub.created_by,
|
|
350
|
+
updated_by: isHiddenRef(pub.updated_by, hidden) ? null : pub.updated_by,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, tags_json, created_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, archived, created_by, created_by_json, updated_by_json, updated_at, parent_id, " +
|
|
355
|
+
"(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
|
|
356
|
+
"(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0 AND c.column_name = 'done') AS children_done";
|
|
295
357
|
|
|
296
358
|
type PersonRef = { kind: 'user' | 'node'; id: string };
|
|
297
|
-
function personRef(value: unknown, networkId: string): PersonRef {
|
|
359
|
+
function personRef(value: unknown, networkId: string, hidden: HiddenNode = null): PersonRef {
|
|
298
360
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid_person');
|
|
299
361
|
const row = value as Record<string, unknown>;
|
|
300
362
|
if ((row.kind !== 'user' && row.kind !== 'node') || typeof row.id !== 'string' || !row.id.trim()) throw new Error('invalid_person');
|
|
301
363
|
const exists = row.kind === 'user'
|
|
302
364
|
? db.get("SELECT 1 FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1 AND m.user_id=?2", networkId, row.id)
|
|
303
365
|
: db.get("SELECT 1 FROM nodes WHERE network_id=?1 AND node_id=?2", networkId, row.id);
|
|
304
|
-
|
|
366
|
+
// 没授权的节点与不存在的节点同一个错误,不给受限成员留 node_id 探测差异。
|
|
367
|
+
if (!exists || (row.kind === 'node' && hidden?.(row.id as string))) throw new Error('person_not_in_network');
|
|
305
368
|
return { kind: row.kind, id: row.id };
|
|
306
369
|
}
|
|
307
370
|
|
|
308
371
|
// 负责人只能是人类,负责 Agent 只能是节点;参与人两种都行。库里旧的节点负责人照样读出来(启动迁移会挪走),
|
|
309
372
|
// 只有这次请求显式写了 owner / agent_owner 才校验种类。
|
|
310
|
-
|
|
373
|
+
// 旧客户端兼容(App ≤ 0.2.142 只有一个「负责人」,可以选节点):请求里 owner 是节点、又**没带** agent_owner
|
|
374
|
+
// → 当成设置负责 Agent,负责人清空,照常 200,并回 owner_coerced_to_agent_owner: true。
|
|
375
|
+
// 带了 agent_owner 的就是新客户端,保持严格:owner 是节点 → 400 owner_must_be_human。
|
|
376
|
+
function assignments(body: Record<string, unknown>, networkId: string, current?: Row, hidden: HiddenNode = null) {
|
|
311
377
|
let owner = current?.owner_json ? JSON.parse(current.owner_json) : null;
|
|
378
|
+
let agentOwner = current?.agent_owner_json ? JSON.parse(current.agent_owner_json) : null;
|
|
379
|
+
let coerced: PersonRef | null = null;
|
|
380
|
+
// 受限成员看不见的负责 Agent,他也不能换掉或清空(他读到的是 null,写回 null 不该抹掉别人的指派)。
|
|
381
|
+
const agentOwnerLocked = isHiddenRef(agentOwner, hidden);
|
|
312
382
|
if ('owner' in body) {
|
|
313
|
-
owner = body.owner === null ? null : personRef(body.owner, networkId);
|
|
314
|
-
if (owner && owner.kind !== 'user')
|
|
383
|
+
owner = body.owner === null ? null : personRef(body.owner, networkId, hidden);
|
|
384
|
+
if (owner && owner.kind !== 'user') {
|
|
385
|
+
if ('agent_owner' in body) throw new Error('owner_must_be_human');
|
|
386
|
+
coerced = owner;
|
|
387
|
+
agentOwner = owner;
|
|
388
|
+
owner = null;
|
|
389
|
+
}
|
|
315
390
|
}
|
|
316
|
-
|
|
391
|
+
if (coerced && agentOwnerLocked) throw new Error('agent_owner_not_granted');
|
|
317
392
|
if ('agent_owner' in body) {
|
|
318
|
-
|
|
393
|
+
if (agentOwnerLocked) throw new Error('agent_owner_not_granted');
|
|
394
|
+
agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId, hidden);
|
|
319
395
|
if (agentOwner && agentOwner.kind !== 'node') throw new Error('agent_owner_must_be_agent');
|
|
320
396
|
}
|
|
321
397
|
let participants = current ? JSON.parse(current.participants_json || '[]') : [];
|
|
322
398
|
if ('participants' in body) {
|
|
323
399
|
if (!Array.isArray(body.participants) || body.participants.length > 100) throw new Error('invalid_participants');
|
|
324
|
-
const refs = body.participants.map(value => personRef(value, networkId));
|
|
325
|
-
|
|
400
|
+
const refs = body.participants.map(value => personRef(value, networkId, hidden));
|
|
401
|
+
// 参与人是整体替换:把受限成员看不见的那些节点原样留下,别让他「保存」时静默删掉。
|
|
402
|
+
const keptHidden = hidden ? participants.filter((ref: unknown) => isHiddenRef(ref, hidden)) : [];
|
|
403
|
+
participants = [...new Map([...keptHidden, ...refs].map((ref: PersonRef) => [`${ref.kind}:${ref.id}`, ref])).values()];
|
|
326
404
|
}
|
|
327
405
|
return {
|
|
406
|
+
coerced,
|
|
328
407
|
ownerJson: owner === null ? null : JSON.stringify(owner),
|
|
329
408
|
agentOwnerJson: agentOwner === null ? null : JSON.stringify(agentOwner),
|
|
330
409
|
participantsJson: JSON.stringify(participants),
|
|
331
410
|
};
|
|
332
411
|
}
|
|
333
412
|
|
|
334
|
-
/**
|
|
335
|
-
|
|
413
|
+
/**
|
|
414
|
+
* 节点令牌(Agent,比如把 GitHub issue 同步进任务的 TM 节点)可用的操作 —— 只在它自己的网络里
|
|
415
|
+
* (令牌绑定的网络,范围由 resolveRestNetworkScope 强制)。删除卡片、管理项目仍然只给人。
|
|
416
|
+
*/
|
|
417
|
+
const NODE_TOKEN_OPERATIONS: ReadonlySet<string> = new Set<string>(["read", "create", "patch", "checklist_item", "upsert", "projects_read"]);
|
|
336
418
|
function operationOf(req: Request, url: URL): string {
|
|
419
|
+
if (/^\/api\/requirements\/projects(\/|$)/.test(url.pathname)) return req.method === "GET" ? "projects_read" : "projects_write";
|
|
337
420
|
if (req.method === "GET") return "read";
|
|
338
|
-
if (/^\/api\/requirements\/projects(\/|$)/.test(url.pathname)) return "projects";
|
|
339
421
|
if (/^\/api\/requirements\/[^/]+\/checklist\/[^/]+$/.test(url.pathname)) return "checklist_item";
|
|
422
|
+
if (url.pathname === "/api/requirements/upsert") return "upsert";
|
|
423
|
+
if (req.method === "DELETE") return "delete";
|
|
340
424
|
if (req.method === "PATCH") return "patch";
|
|
341
425
|
return "create";
|
|
342
426
|
}
|
|
343
427
|
|
|
344
|
-
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime"] as const;
|
|
428
|
+
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags"] as const;
|
|
429
|
+
|
|
430
|
+
// ── 子需求 ──
|
|
431
|
+
// parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
|
|
432
|
+
export const MAX_REQUIREMENT_DEPTH = 5;
|
|
433
|
+
function levelOf(id: string): number {
|
|
434
|
+
let level = 1;
|
|
435
|
+
let cur: string | null = id;
|
|
436
|
+
const seen = new Set<string>();
|
|
437
|
+
while (cur) {
|
|
438
|
+
if (seen.has(cur)) return Number.POSITIVE_INFINITY; // 已存在的环(不该有):当作超深,拒绝挂上去
|
|
439
|
+
seen.add(cur);
|
|
440
|
+
const up = db.get<{ parent_id: string | null }>("SELECT parent_id FROM requirements WHERE requirement_id = ?1", cur);
|
|
441
|
+
cur = up?.parent_id ?? null;
|
|
442
|
+
if (cur) level++;
|
|
443
|
+
}
|
|
444
|
+
return level;
|
|
445
|
+
}
|
|
446
|
+
/** 以 id 为根的子树还有几层(叶子 = 0)。 */
|
|
447
|
+
function heightBelow(id: string): number {
|
|
448
|
+
let height = 0;
|
|
449
|
+
let frontier = [id];
|
|
450
|
+
const seen = new Set<string>([id]);
|
|
451
|
+
while (frontier.length && height <= MAX_REQUIREMENT_DEPTH) {
|
|
452
|
+
const next: string[] = [];
|
|
453
|
+
for (const f of frontier) {
|
|
454
|
+
for (const c of db.all<{ requirement_id: string }>("SELECT requirement_id FROM requirements WHERE parent_id = ?1", f)) {
|
|
455
|
+
if (!seen.has(c.requirement_id)) { seen.add(c.requirement_id); next.push(c.requirement_id); }
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (!next.length) break;
|
|
459
|
+
height++;
|
|
460
|
+
frontier = next;
|
|
461
|
+
}
|
|
462
|
+
return height;
|
|
463
|
+
}
|
|
464
|
+
/** 校验要挂的父卡:同网络、存在、不是自己或自己的后代(不成环)、挂上后不超过 5 层。返回错误码或 null。 */
|
|
465
|
+
function parentError(networkId: string, parentId: string, selfId: string | null): string | null {
|
|
466
|
+
const parent = db.get<{ network_id: string; requirement_id: string }>("SELECT network_id, requirement_id FROM requirements WHERE requirement_id = ?1", parentId);
|
|
467
|
+
if (!parent || parent.network_id !== networkId) return "parent_not_found";
|
|
468
|
+
if (selfId) {
|
|
469
|
+
// 从父卡往上走,碰到自己 = 成环
|
|
470
|
+
let cur: string | null = parentId;
|
|
471
|
+
const seen = new Set<string>();
|
|
472
|
+
while (cur && !seen.has(cur)) {
|
|
473
|
+
if (cur === selfId) return "parent_cycle";
|
|
474
|
+
seen.add(cur);
|
|
475
|
+
cur = db.get<{ parent_id: string | null }>("SELECT parent_id FROM requirements WHERE requirement_id = ?1", cur)?.parent_id ?? null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const depth = levelOf(parentId) + 1 + (selfId ? heightBelow(selfId) : 0);
|
|
479
|
+
return depth > MAX_REQUIREMENT_DEPTH ? "parent_too_deep" : null;
|
|
480
|
+
}
|
|
481
|
+
function parentIdOf(value: unknown): string | null | undefined {
|
|
482
|
+
if (value === null || value === "") return null;
|
|
483
|
+
return typeof value === "string" && /^[A-Za-z0-9_-]{1,200}$/.test(value) ? value : undefined;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── 谁做的 ──
|
|
487
|
+
// 用户令牌 = {kind:"user", id:user_id};节点令牌 = {kind:"node", id:令牌绑定的 node_id}(老令牌没绑 node_id 时按
|
|
488
|
+
// 令牌名里的 alias 在本网络找;都找不到记 token 本身,不冒充任何节点)。写进 created_by / updated_by。
|
|
489
|
+
type Actor = { kind: "user" | "node"; id: string };
|
|
490
|
+
function actorOf(ctx: RequirementsRequestContext): Actor | null {
|
|
491
|
+
if (!ctx.auth) return null;
|
|
492
|
+
if (!ctx.isNodeToken) return { kind: "user", id: ctx.auth.userId };
|
|
493
|
+
const tokenId = ctx.auth.tokenId ?? null;
|
|
494
|
+
const bound = tokenId ? db.get<{ bound_node_id: string | null }>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", tokenId)?.bound_node_id : null;
|
|
495
|
+
if (bound) return { kind: "node", id: bound };
|
|
496
|
+
const alias = ctx.auth.tokenName?.startsWith("node:") ? ctx.auth.tokenName.slice(5) : null;
|
|
497
|
+
if (alias && ctx.auth.networkId) {
|
|
498
|
+
const node = db.get<{ node_id: string }>("SELECT node_id FROM nodes WHERE network_id = ?1 AND alias = ?2", ctx.auth.networkId, alias);
|
|
499
|
+
if (node) return { kind: "node", id: node.node_id };
|
|
500
|
+
}
|
|
501
|
+
return { kind: "node", id: `token:${tokenId ?? "unknown"}` };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ── 外部引用(同步用) ──
|
|
505
|
+
// external_ref:如 github:owner/repo#123,同一网络里唯一(部分唯一索引,见 requirements-migrate.ts)。
|
|
506
|
+
// external_url:http(s) 链接,给界面显示成「在 GitHub 打开」。
|
|
507
|
+
const EXTERNAL_REF = /^[A-Za-z0-9][A-Za-z0-9_.:\/#@+-]{0,199}$/;
|
|
508
|
+
function externalRef(value: unknown): string | null | undefined {
|
|
509
|
+
if (value === null) return null;
|
|
510
|
+
if (typeof value !== "string") return undefined;
|
|
511
|
+
const v = value.trim();
|
|
512
|
+
return EXTERNAL_REF.test(v) ? v : undefined;
|
|
513
|
+
}
|
|
514
|
+
function externalUrl(value: unknown): string | null | undefined {
|
|
515
|
+
if (value === null || value === "") return null;
|
|
516
|
+
if (typeof value !== "string") return undefined;
|
|
517
|
+
const v = value.trim();
|
|
518
|
+
return /^https?:\/\/[^\s]{1,500}$/i.test(v) ? v : undefined;
|
|
519
|
+
}
|
|
520
|
+
function rowByExternalRef(networkId: string, ref: string): Row | undefined {
|
|
521
|
+
return db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND external_ref = ?2`, networkId, ref) ?? undefined;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ── 新建 ──
|
|
525
|
+
async function createRequirement(ctx: RequirementsRequestContext, body: Record<string, unknown>): Promise<Response> {
|
|
526
|
+
const networkId = writeNetwork(body, ctx);
|
|
527
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
528
|
+
if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
529
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
530
|
+
if (!name || name.length > 80) return jsonError("invalid_name", 400);
|
|
531
|
+
const priority = typeof body.priority === "string" ? body.priority : "normal";
|
|
532
|
+
if (!PRIORITIES.has(priority)) return jsonError("invalid_priority", 400);
|
|
533
|
+
const due = typeof body.due === "string" ? normalizeDue(body.due) : "";
|
|
534
|
+
if (due === null) return jsonError("invalid_due", 400);
|
|
535
|
+
const assignee = typeof body.assignee === "string" ? body.assignee.trim().slice(0, 80) : "";
|
|
536
|
+
const clientId = typeof body.client_id === "string" ? body.client_id.trim() : "";
|
|
537
|
+
if (clientId && !/^[A-Za-z0-9._-]{1,80}$/.test(clientId)) return jsonError("invalid_client_id", 400);
|
|
538
|
+
const column = typeof body.column === "string" && COLUMNS.has(body.column) ? body.column : "pool";
|
|
539
|
+
const issues = body.issues === undefined ? [] : normalizeIssues(body.issues);
|
|
540
|
+
if (issues === null) return jsonError("invalid_issues", 400);
|
|
541
|
+
const tags = body.tags === undefined ? [] : normalizeTags(body.tags);
|
|
542
|
+
if (tags === null) return jsonError("invalid_tags", 400);
|
|
543
|
+
const description = body.description === undefined ? "" : normalizeDescription(body.description);
|
|
544
|
+
if (description === null) return jsonError("invalid_description", 400);
|
|
545
|
+
const checklist = body.checklist === undefined ? [] : normalizeChecklist(body.checklist);
|
|
546
|
+
if (checklist === null) return jsonError("invalid_checklist", 400);
|
|
547
|
+
let projectId: string | null = null;
|
|
548
|
+
if (body.project_id !== undefined) {
|
|
549
|
+
try { projectId = projectRef(body.project_id, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
550
|
+
}
|
|
551
|
+
const ref = body.external_ref === undefined ? null : externalRef(body.external_ref);
|
|
552
|
+
if (ref === undefined) return jsonError("invalid_external_ref", 400);
|
|
553
|
+
const extUrl = body.external_url === undefined ? null : externalUrl(body.external_url);
|
|
554
|
+
if (extUrl === undefined) return jsonError("invalid_external_url", 400);
|
|
555
|
+
const parentId = body.parent_id === undefined ? null : parentIdOf(body.parent_id);
|
|
556
|
+
if (parentId === undefined) return jsonError("invalid_parent_id", 400);
|
|
557
|
+
if (parentId) {
|
|
558
|
+
const err = parentError(networkId, parentId, null);
|
|
559
|
+
if (err) return jsonError(err, 400);
|
|
560
|
+
}
|
|
561
|
+
if (ref) {
|
|
562
|
+
const existing = rowByExternalRef(networkId, ref);
|
|
563
|
+
// 同一个外部条目再建一次 = 冲突,回已有的 id(同步方改用 upsert,或按这个 id PATCH)。
|
|
564
|
+
if (existing) return Response.json({ ok: false, error: "external_ref_exists", existing_id: existing.requirement_id }, { status: 409 });
|
|
565
|
+
}
|
|
566
|
+
if (clientId) {
|
|
567
|
+
const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
|
|
568
|
+
if (existing) return Response.json({ ok: true, requirement: toPublicFor(ctx, existing) });
|
|
569
|
+
}
|
|
570
|
+
const id = `req_${crypto.randomUUID()}`;
|
|
571
|
+
const createdAt = new Date().toISOString();
|
|
572
|
+
let people;
|
|
573
|
+
try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId)); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
574
|
+
const actor = JSON.stringify(actorOf(ctx));
|
|
575
|
+
try {
|
|
576
|
+
db.run(
|
|
577
|
+
`INSERT INTO requirements
|
|
578
|
+
(requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, created_by_json, updated_by_json, archived, parent_id, tags_json)
|
|
579
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?20, 0, ?21, ?22)`,
|
|
580
|
+
[id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson, people.agentOwnerJson, description || null, JSON.stringify(checklist), projectId, ref, extUrl, actor, parentId, JSON.stringify(tags)],
|
|
581
|
+
);
|
|
582
|
+
} catch {
|
|
583
|
+
// 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
|
|
584
|
+
if (ref) {
|
|
585
|
+
const existing = rowByExternalRef(networkId, ref);
|
|
586
|
+
if (existing) return Response.json({ ok: false, error: "external_ref_exists", existing_id: existing.requirement_id }, { status: 409 });
|
|
587
|
+
}
|
|
588
|
+
if (!clientId) return jsonError("insert_failed", 500);
|
|
589
|
+
const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
|
|
590
|
+
if (!existing) return jsonError("insert_failed", 500);
|
|
591
|
+
return Response.json({ ok: true, requirement: toPublicFor(ctx, existing) });
|
|
592
|
+
}
|
|
593
|
+
const created = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, id)!;
|
|
594
|
+
return Response.json(withLegacyOwner(toPublicFor(ctx, created), people.coerced), { status: 201 });
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ── 修改(省略的字段保留原值) ──
|
|
598
|
+
const PATCH_FIELDS = ["column", "issues", "tags", "name", "priority", "due", "assignee", "owner", "agent_owner", "participants", "description", "checklist", "project_id", "external_ref", "external_url", "archived", "parent_id"];
|
|
599
|
+
function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Record<string, unknown>): Response {
|
|
600
|
+
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
|
|
601
|
+
const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
|
|
602
|
+
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
603
|
+
if (has("column") && !COLUMNS.has(String(body.column))) return jsonError("invalid_column", 400);
|
|
604
|
+
const issues = has("issues") ? normalizeIssues(body.issues) : null;
|
|
605
|
+
if (has("issues") && issues === null) return jsonError("invalid_issues", 400);
|
|
606
|
+
const tags = has("tags") ? normalizeTags(body.tags) : null;
|
|
607
|
+
if (has("tags") && tags === null) return jsonError("invalid_tags", 400);
|
|
608
|
+
const description = has("description") ? normalizeDescription(body.description) : null;
|
|
609
|
+
if (has("description") && description === null) return jsonError("invalid_description", 400);
|
|
610
|
+
const checklist = has("checklist") ? normalizeChecklist(body.checklist) : null;
|
|
611
|
+
if (has("checklist") && checklist === null) return jsonError("invalid_checklist", 400);
|
|
612
|
+
let name = row.title;
|
|
613
|
+
if (has("name")) {
|
|
614
|
+
name = typeof body.name === "string" ? body.name.trim() : "";
|
|
615
|
+
if (!name || name.length > 80) return jsonError("invalid_name", 400);
|
|
616
|
+
}
|
|
617
|
+
let priority = row.priority;
|
|
618
|
+
if (has("priority")) {
|
|
619
|
+
if (typeof body.priority !== "string" || !PRIORITIES.has(body.priority)) return jsonError("invalid_priority", 400);
|
|
620
|
+
priority = body.priority;
|
|
621
|
+
}
|
|
622
|
+
let due = row.due_on || "";
|
|
623
|
+
if (has("due")) {
|
|
624
|
+
if (typeof body.due !== "string") return jsonError("invalid_due", 400);
|
|
625
|
+
const next = normalizeDue(body.due);
|
|
626
|
+
if (next === null) return jsonError("invalid_due", 400);
|
|
627
|
+
due = next;
|
|
628
|
+
}
|
|
629
|
+
let assignee = row.assignee || "";
|
|
630
|
+
if (has("assignee")) {
|
|
631
|
+
if (typeof body.assignee !== "string") return jsonError("invalid_assignee", 400);
|
|
632
|
+
assignee = body.assignee.trim().slice(0, 80);
|
|
633
|
+
}
|
|
634
|
+
let people;
|
|
635
|
+
try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id)); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
636
|
+
let projectId = row.project_id;
|
|
637
|
+
if (has("project_id")) {
|
|
638
|
+
try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
639
|
+
}
|
|
640
|
+
let ref = row.external_ref;
|
|
641
|
+
if (has("external_ref")) {
|
|
642
|
+
const next = externalRef(body.external_ref);
|
|
643
|
+
if (next === undefined) return jsonError("invalid_external_ref", 400);
|
|
644
|
+
if (next && next !== row.external_ref) {
|
|
645
|
+
const taken = rowByExternalRef(row.network_id, next);
|
|
646
|
+
if (taken) return Response.json({ ok: false, error: "external_ref_exists", existing_id: taken.requirement_id }, { status: 409 });
|
|
647
|
+
}
|
|
648
|
+
ref = next;
|
|
649
|
+
}
|
|
650
|
+
let extUrl = row.external_url;
|
|
651
|
+
if (has("external_url")) {
|
|
652
|
+
const next = externalUrl(body.external_url);
|
|
653
|
+
if (next === undefined) return jsonError("invalid_external_url", 400);
|
|
654
|
+
extUrl = next;
|
|
655
|
+
}
|
|
656
|
+
let parentId = row.parent_id;
|
|
657
|
+
if (has("parent_id")) {
|
|
658
|
+
const next = parentIdOf(body.parent_id);
|
|
659
|
+
if (next === undefined) return jsonError("invalid_parent_id", 400);
|
|
660
|
+
if (next) {
|
|
661
|
+
const err = parentError(row.network_id, next, row.requirement_id);
|
|
662
|
+
if (err) return jsonError(err, 400);
|
|
663
|
+
}
|
|
664
|
+
parentId = next;
|
|
665
|
+
}
|
|
666
|
+
let archived = row.archived ? 1 : 0;
|
|
667
|
+
if (has("archived")) {
|
|
668
|
+
if (typeof body.archived !== "boolean") return jsonError("invalid_archived", 400);
|
|
669
|
+
archived = body.archived ? 1 : 0;
|
|
670
|
+
}
|
|
671
|
+
const updatedAt = new Date().toISOString();
|
|
672
|
+
db.run(
|
|
673
|
+
`UPDATE requirements SET column_name = ?1, updated_at = ?2, title = ?7, priority = ?8, due_on = ?9, assignee = ?10, owner_json = ?4, participants_json = ?5, issues_json = ?6,
|
|
674
|
+
agent_owner_json = ?11, description = ?12, checklist_json = ?13, project_id = ?14, external_ref = ?15, external_url = ?16, archived = ?17, updated_by_json = ?18, parent_id = ?19, tags_json = ?20
|
|
675
|
+
WHERE requirement_id = ?3`,
|
|
676
|
+
[has("column") ? body.column : row.column_name, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, has("issues") ? JSON.stringify(issues) : row.issues_json, name, priority, due || null, assignee, people.agentOwnerJson,
|
|
677
|
+
has("description") ? (description || null) : row.description, has("checklist") ? JSON.stringify(checklist) : row.checklist_json, projectId, ref, extUrl, archived, JSON.stringify(actorOf(ctx)), parentId, has("tags") ? JSON.stringify(tags) : row.tags_json],
|
|
678
|
+
);
|
|
679
|
+
const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
680
|
+
return Response.json(withLegacyOwner(toPublicFor(ctx, updated), people.coerced));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* 被兼容改写的请求(旧客户端):响应里的 owner 按旧客户端的理解回显那个节点(它会拿响应核对「保存生效没有」,
|
|
685
|
+
* 0.2.142 的核对不通过就报「这个 Hub 还不能修改」),真实存储是 owner 空、agent_owner = 节点;agent_owner 也一并返回。
|
|
686
|
+
* GET 不这么做:新 App 读 owner / agent_owner 两个字段,GET 回显会让新 App 把 Agent 当成负责人。
|
|
687
|
+
*/
|
|
688
|
+
function withLegacyOwner(requirement: ReturnType<typeof toPublic>, coerced: PersonRef | null) {
|
|
689
|
+
if (!coerced) return { ok: true, requirement };
|
|
690
|
+
return { ok: true, requirement: { ...requirement, owner: coerced }, owner_coerced_to_agent_owner: true };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function scopedRow(ctx: RequirementsRequestContext, id: string): Row | undefined {
|
|
694
|
+
const params: unknown[] = [id];
|
|
695
|
+
let sql = `SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`;
|
|
696
|
+
sql = addHumanNetworkScope(sql, params, ctx.scope);
|
|
697
|
+
return db.get<Row>(sql, ...params) ?? undefined;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** GET 的筛选(同步方 / MCP 用):status、project_id、owner / agent_owner(kind:id)、updated_since、external_ref、parent_id / top_level、include_archived。 */
|
|
701
|
+
function listFilters(url: URL, sql: string, params: unknown[], ctx?: RequirementsRequestContext): string | Response {
|
|
702
|
+
const q = url.searchParams;
|
|
703
|
+
const status = q.get("status");
|
|
704
|
+
if (status !== null) {
|
|
705
|
+
if (!COLUMNS.has(status)) return jsonError("invalid_status", 400);
|
|
706
|
+
sql += ` AND column_name = ?${params.push(status)}`;
|
|
707
|
+
}
|
|
708
|
+
const project = q.get("project_id");
|
|
709
|
+
if (project !== null) sql += project === "none" ? " AND project_id IS NULL" : ` AND project_id = ?${params.push(project)}`;
|
|
710
|
+
for (const [param, column] of [["owner", "owner_json"], ["agent_owner", "agent_owner_json"]] as const) {
|
|
711
|
+
const v = q.get(param);
|
|
712
|
+
if (v === null) continue;
|
|
713
|
+
if (v === "none") { sql += ` AND ${column} IS NULL`; continue; }
|
|
714
|
+
const m = /^(user|node):(.+)$/.exec(v);
|
|
715
|
+
if (!m) return jsonError(`invalid_${param}`, 400);
|
|
716
|
+
// 受限成员按一个没授权给他的节点筛选:与「没有这样的卡」同一结果(否则是「谁负责这张卡」的探测器)。
|
|
717
|
+
if (m[1] === "node" && ctx && hiddenFilterForScope(ctx, m[2])) { sql += " AND 1=0"; continue; }
|
|
718
|
+
sql += ` AND ${column} = ?${params.push(JSON.stringify({ kind: m[1], id: m[2] }))}`;
|
|
719
|
+
}
|
|
720
|
+
const since = q.get("updated_since");
|
|
721
|
+
if (since !== null) {
|
|
722
|
+
const ms = Date.parse(since);
|
|
723
|
+
if (!Number.isFinite(ms)) return jsonError("invalid_updated_since", 400);
|
|
724
|
+
sql += ` AND updated_at >= ?${params.push(new Date(ms).toISOString())}`;
|
|
725
|
+
}
|
|
726
|
+
const ref = q.get("external_ref");
|
|
727
|
+
if (ref !== null) sql += ` AND external_ref = ?${params.push(ref)}`;
|
|
728
|
+
const parent = q.get("parent_id");
|
|
729
|
+
if (parent !== null) sql += parent === "none" ? " AND parent_id IS NULL" : ` AND parent_id = ?${params.push(parent)}`;
|
|
730
|
+
if (q.get("top_level") === "1") sql += " AND parent_id IS NULL";
|
|
731
|
+
// Explicit archived-only wins over the legacy include-all switch.
|
|
732
|
+
if (q.get("archived") === "true") sql += " AND COALESCE(archived, 0) = 1";
|
|
733
|
+
else if (q.get("include_archived") !== "1") sql += " AND COALESCE(archived, 0) = 0";
|
|
734
|
+
return sql;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** 这个节点对调用者在他作用域内的某个受限网络里是否隐藏。 */
|
|
738
|
+
function hiddenFilterForScope(ctx: RequirementsRequestContext, nodeId: string): boolean {
|
|
739
|
+
const restricted = ctx.scope.agentRestriction?.networkIds ?? [];
|
|
740
|
+
for (const networkId of restricted) {
|
|
741
|
+
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
742
|
+
if (hidden?.(nodeId)) return true;
|
|
743
|
+
}
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
345
746
|
|
|
346
747
|
export async function handleRequirementsRequest(ctx: RequirementsRequestContext): Promise<Response | null> {
|
|
347
748
|
const { req, url } = ctx;
|
|
348
749
|
if (url.pathname !== "/api/requirements" && !url.pathname.startsWith("/api/requirements/")) return null;
|
|
349
|
-
//
|
|
350
|
-
//
|
|
750
|
+
// 节点令牌只能做 NODE_TOKEN_OPERATIONS 里的事(读 / 建 / 改 / 勾子任务 / upsert / 读项目),
|
|
751
|
+
// 而且只在它绑定的网络里 —— 范围由 scope 强制,写入再由 canWrite 核一次。
|
|
351
752
|
if (ctx.isNodeToken && !NODE_TOKEN_OPERATIONS.has(operationOf(req, url))) return jsonError("user_token_required", 403);
|
|
352
753
|
|
|
353
754
|
if (url.pathname === "/api/requirements/projects" || url.pathname.startsWith("/api/requirements/projects/")) return handleProjects(ctx);
|
|
354
755
|
|
|
756
|
+
if (url.pathname === "/api/requirements/tags" && req.method === "GET") {
|
|
757
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
758
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
759
|
+
const rows = db.all<{ tags_json: string | null }>("SELECT tags_json FROM requirements WHERE network_id=?1", networkId);
|
|
760
|
+
const tags = [...new Set(rows.flatMap(row => storedTags(row.tags_json)))].sort();
|
|
761
|
+
return Response.json({ ok: true, networkId, tags });
|
|
762
|
+
}
|
|
763
|
+
|
|
355
764
|
if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
|
|
356
765
|
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
357
766
|
if (!networkId) return jsonError('network_id_required', 400);
|
|
358
767
|
const users = db.all<{ id: string; name: string }>(
|
|
359
768
|
"SELECT u.user_id AS id, COALESCE(NULLIF(u.display_name,''), u.username) AS name FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1 ORDER BY name, id", networkId,
|
|
360
769
|
);
|
|
770
|
+
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
361
771
|
const nodes = db.all<{ id: string; name: string }>(
|
|
362
772
|
"SELECT node_id AS id, COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name FROM nodes WHERE network_id=?1 ORDER BY name, id", networkId,
|
|
363
|
-
);
|
|
773
|
+
).filter(row => !hidden?.(row.id));
|
|
364
774
|
return Response.json({ ok: true, people: [...users.map(row => ({ ...row, kind: 'user', networkId })), ...nodes.map(row => ({ ...row, kind: 'node', networkId }))] });
|
|
365
775
|
}
|
|
366
776
|
|
|
367
777
|
if (url.pathname === "/api/requirements" && req.method === "GET") {
|
|
368
778
|
const params: unknown[] = [];
|
|
369
779
|
let sql = `SELECT ${SELECT} FROM requirements WHERE 1=1`;
|
|
370
|
-
sql =
|
|
371
|
-
|
|
372
|
-
|
|
780
|
+
sql = addHumanNetworkScope(sql, params, ctx.scope);
|
|
781
|
+
const filtered = listFilters(url, sql, params, ctx);
|
|
782
|
+
if (typeof filtered !== "string") return filtered;
|
|
783
|
+
const rows = db.all<Row>(`${filtered} ORDER BY created_at DESC LIMIT 500`, ...params).map(row => toPublicFor(ctx, row));
|
|
373
784
|
// capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
|
|
374
785
|
return Response.json({ ok: true, requirements: rows, capabilities: REQUIREMENT_CAPABILITIES });
|
|
375
786
|
}
|
|
@@ -377,58 +788,32 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
377
788
|
if (url.pathname === "/api/requirements" && req.method === "POST") {
|
|
378
789
|
let body: Record<string, unknown>;
|
|
379
790
|
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
791
|
+
return createRequirement(ctx, body);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// 按 external_ref 建或改(同步用,幂等):同一网络里已有这个 external_ref → 按 PATCH 语义改它
|
|
795
|
+
// (省略的字段保留,包括状态);没有 → 新建。重复同步同一个 issue 只会改,不会重复建。
|
|
796
|
+
if (url.pathname === "/api/requirements/upsert" && req.method === "POST") {
|
|
797
|
+
let body: Record<string, unknown>;
|
|
798
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
799
|
+
const ref = externalRef(body.external_ref);
|
|
800
|
+
if (!ref) return jsonError("external_ref_required", 400);
|
|
380
801
|
const networkId = writeNetwork(body, ctx);
|
|
381
802
|
if (!networkId) return jsonError("network_id_required", 400);
|
|
382
803
|
if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
383
|
-
const
|
|
384
|
-
if (!
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const assignee = typeof body.assignee === "string" ? body.assignee.trim().slice(0, 80) : "";
|
|
390
|
-
const clientId = typeof body.client_id === "string" ? body.client_id.trim() : "";
|
|
391
|
-
if (clientId && !/^[A-Za-z0-9._-]{1,80}$/.test(clientId)) return jsonError("invalid_client_id", 400);
|
|
392
|
-
const column = typeof body.column === "string" && COLUMNS.has(body.column) ? body.column : "pool";
|
|
393
|
-
const issues = body.issues === undefined ? [] : normalizeIssues(body.issues);
|
|
394
|
-
if (issues === null) return jsonError("invalid_issues", 400);
|
|
395
|
-
const description = body.description === undefined ? "" : normalizeDescription(body.description);
|
|
396
|
-
if (description === null) return jsonError("invalid_description", 400);
|
|
397
|
-
const checklist = body.checklist === undefined ? [] : normalizeChecklist(body.checklist);
|
|
398
|
-
if (checklist === null) return jsonError("invalid_checklist", 400);
|
|
399
|
-
let projectId: string | null = null;
|
|
400
|
-
if (body.project_id !== undefined) {
|
|
401
|
-
try { projectId = projectRef(body.project_id, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
402
|
-
}
|
|
403
|
-
if (clientId) {
|
|
404
|
-
const existing = db.get<Row>(
|
|
405
|
-
`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`,
|
|
406
|
-
networkId, clientId,
|
|
407
|
-
);
|
|
408
|
-
if (existing) return Response.json({ ok: true, requirement: toPublic(existing) });
|
|
804
|
+
const existing = rowByExternalRef(networkId, ref);
|
|
805
|
+
if (!existing) {
|
|
806
|
+
const res = await createRequirement(ctx, body);
|
|
807
|
+
if (res.status !== 201) return res;
|
|
808
|
+
const data = await res.json() as Record<string, unknown>;
|
|
809
|
+
return Response.json({ ...data, created: true }, { status: 201 });
|
|
409
810
|
}
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
`INSERT INTO requirements
|
|
417
|
-
(requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id)
|
|
418
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13, ?14, ?15, ?16, ?17)`,
|
|
419
|
-
[id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson, people.agentOwnerJson, description || null, JSON.stringify(checklist), projectId],
|
|
420
|
-
);
|
|
421
|
-
} catch {
|
|
422
|
-
if (!clientId) return jsonError("insert_failed", 500);
|
|
423
|
-
const existing = db.get<Row>(
|
|
424
|
-
`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`,
|
|
425
|
-
networkId, clientId,
|
|
426
|
-
);
|
|
427
|
-
if (!existing) return jsonError("insert_failed", 500);
|
|
428
|
-
return Response.json({ ok: true, requirement: toPublic(existing) });
|
|
429
|
-
}
|
|
430
|
-
const created = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, id)!;
|
|
431
|
-
return Response.json({ ok: true, requirement: toPublic(created) }, { status: 201 });
|
|
811
|
+
const { external_ref: _ref, network_id: _net, client_id: _client, ...patch } = body;
|
|
812
|
+
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(patch, k))) return Response.json({ ok: true, requirement: toPublicFor(ctx, existing), created: false });
|
|
813
|
+
const res = patchRequirement(ctx, existing, patch);
|
|
814
|
+
if (res.status !== 200) return res;
|
|
815
|
+
const data = await res.json() as Record<string, unknown>;
|
|
816
|
+
return Response.json({ ...data, created: false });
|
|
432
817
|
}
|
|
433
818
|
|
|
434
819
|
const itemMatch = url.pathname.match(/^\/api\/requirements\/([^/]+)\/checklist\/([^/]+)$/);
|
|
@@ -437,10 +822,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
437
822
|
let body: Record<string, unknown>;
|
|
438
823
|
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
439
824
|
if (typeof body.done !== "boolean") return jsonError("invalid_done", 400);
|
|
440
|
-
const
|
|
441
|
-
let sql = `SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`;
|
|
442
|
-
sql = addNetworkScope(sql, params, ctx.scope);
|
|
443
|
-
const row = db.get<Row>(sql, ...params);
|
|
825
|
+
const row = scopedRow(ctx, decodeURIComponent(itemMatch[1]));
|
|
444
826
|
if (!row) return jsonError("requirement_not_found", 404);
|
|
445
827
|
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
446
828
|
// 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
|
|
@@ -449,73 +831,33 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
449
831
|
const item = items.find(entry => entry.id === itemId);
|
|
450
832
|
if (!item) return jsonError("checklist_item_not_found", 404);
|
|
451
833
|
item.done = body.done;
|
|
452
|
-
db.run("UPDATE requirements SET checklist_json = ?1, updated_at = ?2 WHERE requirement_id = ?
|
|
834
|
+
db.run("UPDATE requirements SET checklist_json = ?1, updated_at = ?2, updated_by_json = ?3 WHERE requirement_id = ?4", [JSON.stringify(items), new Date().toISOString(), JSON.stringify(actorOf(ctx)), row.requirement_id]);
|
|
453
835
|
const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
454
|
-
return Response.json({ ok: true, requirement:
|
|
836
|
+
return Response.json({ ok: true, requirement: toPublicFor(ctx, updated) });
|
|
455
837
|
}
|
|
456
838
|
|
|
457
839
|
const match = url.pathname.match(/^\/api\/requirements\/([^/]+)$/);
|
|
458
|
-
if (!match
|
|
840
|
+
if (!match) return jsonError("not_found", 404);
|
|
459
841
|
const id = decodeURIComponent(match[1]);
|
|
842
|
+
if (req.method === "GET") {
|
|
843
|
+
const row = scopedRow(ctx, id);
|
|
844
|
+
return row ? Response.json({ ok: true, requirement: toPublicFor(ctx, row) }) : jsonError("requirement_not_found", 404);
|
|
845
|
+
}
|
|
846
|
+
if (req.method === "DELETE") {
|
|
847
|
+
// 删除只给人(节点令牌在入口已被拒):Agent 用 archived 归档。
|
|
848
|
+
const row = scopedRow(ctx, id);
|
|
849
|
+
if (!row) return jsonError("requirement_not_found", 404);
|
|
850
|
+
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
851
|
+
// 子需求不跟着删:先解挂(变成顶层),再删父卡。
|
|
852
|
+
db.run("UPDATE requirements SET parent_id = NULL WHERE parent_id = ?1", [row.requirement_id]);
|
|
853
|
+
db.run("DELETE FROM requirements WHERE requirement_id = ?1", [row.requirement_id]);
|
|
854
|
+
return Response.json({ ok: true, deleted: row.requirement_id });
|
|
855
|
+
}
|
|
856
|
+
if (req.method !== "PATCH") return jsonError("not_found", 404);
|
|
460
857
|
let body: Record<string, unknown>;
|
|
461
858
|
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
const hasName = 'name' in body;
|
|
465
|
-
const hasPriority = 'priority' in body;
|
|
466
|
-
const hasDue = 'due' in body;
|
|
467
|
-
const hasAssignee = 'assignee' in body;
|
|
468
|
-
const hasDescription = 'description' in body;
|
|
469
|
-
const hasChecklist = 'checklist' in body;
|
|
470
|
-
const hasProject = 'project_id' in body;
|
|
471
|
-
if (!hasColumn && !hasIssues && !hasName && !hasPriority && !hasDue && !hasAssignee && !('owner' in body) && !('agent_owner' in body) && !('participants' in body) && !hasDescription && !hasChecklist && !hasProject) return jsonError('empty_patch', 400);
|
|
472
|
-
const description = hasDescription ? normalizeDescription(body.description) : null;
|
|
473
|
-
if (hasDescription && description === null) return jsonError("invalid_description", 400);
|
|
474
|
-
const checklist = hasChecklist ? normalizeChecklist(body.checklist) : null;
|
|
475
|
-
if (hasChecklist && checklist === null) return jsonError("invalid_checklist", 400);
|
|
476
|
-
if (hasColumn && !COLUMNS.has(String(body.column))) return jsonError("invalid_column", 400);
|
|
477
|
-
const issues = hasIssues ? normalizeIssues(body.issues) : null;
|
|
478
|
-
if (hasIssues && issues === null) return jsonError("invalid_issues", 400);
|
|
479
|
-
const params: unknown[] = [id];
|
|
480
|
-
let sql = `SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`;
|
|
481
|
-
sql = addNetworkScope(sql, params, ctx.scope);
|
|
482
|
-
const row = db.get<Row>(sql, ...params);
|
|
859
|
+
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
|
|
860
|
+
const row = scopedRow(ctx, id);
|
|
483
861
|
if (!row) return jsonError("requirement_not_found", 404);
|
|
484
|
-
|
|
485
|
-
let name = row.title;
|
|
486
|
-
if (hasName) {
|
|
487
|
-
name = typeof body.name === "string" ? body.name.trim() : "";
|
|
488
|
-
if (!name || name.length > 80) return jsonError("invalid_name", 400);
|
|
489
|
-
}
|
|
490
|
-
let priority = row.priority;
|
|
491
|
-
if (hasPriority) {
|
|
492
|
-
if (typeof body.priority !== "string" || !PRIORITIES.has(body.priority)) return jsonError("invalid_priority", 400);
|
|
493
|
-
priority = body.priority;
|
|
494
|
-
}
|
|
495
|
-
let due = row.due_on || "";
|
|
496
|
-
if (hasDue) {
|
|
497
|
-
if (typeof body.due !== "string") return jsonError("invalid_due", 400);
|
|
498
|
-
const next = normalizeDue(body.due);
|
|
499
|
-
if (next === null) return jsonError("invalid_due", 400);
|
|
500
|
-
due = next;
|
|
501
|
-
}
|
|
502
|
-
let assignee = row.assignee || "";
|
|
503
|
-
if (hasAssignee) {
|
|
504
|
-
if (typeof body.assignee !== "string") return jsonError("invalid_assignee", 400);
|
|
505
|
-
assignee = body.assignee.trim().slice(0, 80);
|
|
506
|
-
}
|
|
507
|
-
let people;
|
|
508
|
-
try { people = assignments(body, row.network_id, row); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
509
|
-
let projectId = row.project_id;
|
|
510
|
-
if (hasProject) {
|
|
511
|
-
try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
512
|
-
}
|
|
513
|
-
const updatedAt = new Date().toISOString();
|
|
514
|
-
db.run(
|
|
515
|
-
"UPDATE requirements SET column_name = ?1, updated_at = ?2, title = ?7, priority = ?8, due_on = ?9, assignee = ?10, owner_json = ?4, participants_json = ?5, issues_json = ?6, agent_owner_json = ?11, description = ?12, checklist_json = ?13, project_id = ?14 WHERE requirement_id = ?3",
|
|
516
|
-
[hasColumn ? body.column : row.column_name, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, hasIssues ? JSON.stringify(issues) : row.issues_json, name, priority, due || null, assignee, people.agentOwnerJson,
|
|
517
|
-
hasDescription ? (description || null) : row.description, hasChecklist ? JSON.stringify(checklist) : row.checklist_json, projectId],
|
|
518
|
-
);
|
|
519
|
-
const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
520
|
-
return Response.json({ ok: true, requirement: toPublic(updated) });
|
|
862
|
+
return patchRequirement(ctx, row, body);
|
|
521
863
|
}
|