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