@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.
@@ -1,15 +1,18 @@
1
1
  // 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
2
2
  // 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
3
3
  import { db } from "./db.js";
4
- import { addNetworkScope, canRestWriteNetwork, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
5
- import { ensureRequirementProjects, migrateRequirementAgentOwners } from "./requirements-migrate.js";
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
- return !ctx.isNodeToken && canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
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
- const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, created_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id";
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
- if (!exists) throw new Error('person_not_in_network');
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
- function assignments(body: Record<string, unknown>, networkId: string, current?: Row) {
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') throw new Error('owner_must_be_human');
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
- let agentOwner = current?.agent_owner_json ? JSON.parse(current.agent_owner_json) : null;
391
+ if (coerced && agentOwnerLocked) throw new Error('agent_owner_not_granted');
317
392
  if ('agent_owner' in body) {
318
- agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId);
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
- participants = [...new Map(refs.map(ref => [`${ref.kind}:${ref.id}`, ref])).values()];
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
- /** 节点令牌可用的操作。空 = 与之前一样全部拒绝(本 PR 不放宽)。候选:'read' | 'patch' | 'checklist_item'。 */
335
- const NODE_TOKEN_OPERATIONS: ReadonlySet<string> = new Set<string>();
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
- // 节点令牌(Agent)现在一律拒绝。以后要让 Agent 读写描述、勾子任务,把对应操作加进
350
- // NODE_TOKEN_OPERATIONS(并给 canWrite 加上节点所属网络的判断)即可 —— 路由和请求体不用改。
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 = addNetworkScope(sql, params, ctx.scope);
371
- sql += " ORDER BY created_at DESC LIMIT 500";
372
- const rows = db.all<Row>(sql, ...params).map(toPublic);
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 name = typeof body.name === "string" ? body.name.trim() : "";
384
- if (!name || name.length > 80) return jsonError("invalid_name", 400);
385
- const priority = typeof body.priority === "string" ? body.priority : "normal";
386
- if (!PRIORITIES.has(priority)) return jsonError("invalid_priority", 400);
387
- const due = typeof body.due === "string" ? normalizeDue(body.due) : "";
388
- if (due === null) return jsonError("invalid_due", 400);
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 id = `req_${crypto.randomUUID()}`;
411
- const createdAt = new Date().toISOString();
412
- let people;
413
- try { people = assignments(body, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
414
- try {
415
- db.run(
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 params: unknown[] = [decodeURIComponent(itemMatch[1])];
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 = ?3", [JSON.stringify(items), new Date().toISOString(), row.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: toPublic(updated) });
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 || req.method !== "PATCH") return jsonError("not_found", 404);
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
- const hasColumn = 'column' in body;
463
- const hasIssues = Object.prototype.hasOwnProperty.call(body, "issues");
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
- if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
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
  }