@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.70
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/README.md +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +88 -0
- 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/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +348 -29
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +762 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +149 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/push.ts +204 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +865 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +10 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +574 -0
- package/src/scheduled-tasks.ts +596 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4279 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3445 -257
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
// 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
|
|
2
|
+
// 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
|
|
3
|
+
import { db } from "./db.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, migrateRequirementPriorityCheck } from "./requirements-migrate.js";
|
|
8
|
+
|
|
9
|
+
// 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
|
|
10
|
+
// 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
|
|
11
|
+
migrateRequirementPriorityCheck(db);
|
|
12
|
+
migrateRequirementAgentOwners(db);
|
|
13
|
+
ensureRequirementProjects(db);
|
|
14
|
+
ensureRequirementIndexes(db);
|
|
15
|
+
|
|
16
|
+
type RequestAuth = { userId: string; networkId: string | null; username: string; tokenId?: string | null; tokenName?: string | null } | null;
|
|
17
|
+
|
|
18
|
+
export type RequirementsRequestContext = {
|
|
19
|
+
req: Request;
|
|
20
|
+
url: URL;
|
|
21
|
+
auth: RequestAuth;
|
|
22
|
+
isAdmin: boolean;
|
|
23
|
+
isNodeToken: boolean;
|
|
24
|
+
scope: RestNetworkScope;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const COLUMNS = new Set(["pool", "doing", "done"]);
|
|
28
|
+
// 界面上 P0–P3:high=P0 最高、normal=P1 普通、low=P2 低、lowest=P3 极低。存的值不变,旧客户端照常读写前三个。
|
|
29
|
+
const PRIORITIES = new Set(["high", "normal", "low", "lowest"]);
|
|
30
|
+
const DUE = /^\d{4}-\d{2}-\d{2}$/;
|
|
31
|
+
|
|
32
|
+
type Row = {
|
|
33
|
+
owner_json: string | null;
|
|
34
|
+
agent_owner_json: string | null;
|
|
35
|
+
description: string | null;
|
|
36
|
+
checklist_json: string | null;
|
|
37
|
+
project_id: string | null;
|
|
38
|
+
external_ref: string | null;
|
|
39
|
+
external_url: string | null;
|
|
40
|
+
archived: number | null;
|
|
41
|
+
created_by: string | null;
|
|
42
|
+
created_by_json: string | null;
|
|
43
|
+
updated_by_json: string | null;
|
|
44
|
+
updated_at: string | null;
|
|
45
|
+
parent_id: string | null;
|
|
46
|
+
children_total: number | null;
|
|
47
|
+
children_done: number | null;
|
|
48
|
+
participants_json: string;
|
|
49
|
+
requirement_id: string;
|
|
50
|
+
network_id: string;
|
|
51
|
+
title: string;
|
|
52
|
+
column_name: string;
|
|
53
|
+
priority: string;
|
|
54
|
+
due_on: string | null;
|
|
55
|
+
assignee: string | null;
|
|
56
|
+
issues_json: string | null;
|
|
57
|
+
tags_json: string | null;
|
|
58
|
+
created_at: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function jsonError(error: string, status: number): Response {
|
|
62
|
+
return Response.json({ ok: false, error }, { status });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function dueOk(due: string): boolean {
|
|
66
|
+
if (!DUE.test(due)) return false;
|
|
67
|
+
const [y, m, d] = due.split("-").map(Number);
|
|
68
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
69
|
+
return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 预计完成:两种形状都收。
|
|
73
|
+
// YYYY-MM-DD 全天(旧值原样保存、原样返回;客户端按查看者本地时区的那一天理解)
|
|
74
|
+
// YYYY-MM-DDTHH:MM[:SS][.fff](Z|±HH:MM) 精确到秒的时刻 → 统一存成 UTC「YYYY-MM-DDTHH:MM:SSZ」
|
|
75
|
+
// 不带时区的时刻拒收(无法知道是谁的本地时间)。返回 null = 不合法。
|
|
76
|
+
const DUE_DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d{1,9})?(Z|[+-](\d{2}):(\d{2}))$/;
|
|
77
|
+
export function normalizeDue(raw: string): string | null {
|
|
78
|
+
const due = raw.trim();
|
|
79
|
+
if (!due) return "";
|
|
80
|
+
if (dueOk(due)) return due;
|
|
81
|
+
const m = DUE_DATETIME.exec(due);
|
|
82
|
+
if (!m) return null;
|
|
83
|
+
if (!dueOk(`${m[1]}-${m[2]}-${m[3]}`)) return null;
|
|
84
|
+
const [hh, mm, ss] = [Number(m[4]), Number(m[5]), Number(m[6] ?? 0)];
|
|
85
|
+
if (hh > 23 || mm > 59 || ss > 59) return null;
|
|
86
|
+
if (m[7] !== "Z" && (Number(m[8]) > 14 || Number(m[9]) > 59)) return null;
|
|
87
|
+
const ms = Date.parse(due);
|
|
88
|
+
if (!Number.isFinite(ms)) return null;
|
|
89
|
+
return new Date(Math.floor(ms / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
93
|
+
const ISSUE_URL = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/issues\/(\d+)\/?$/;
|
|
94
|
+
|
|
95
|
+
function clipTitle(value: unknown): string {
|
|
96
|
+
return typeof value === "string" ? value.replace(/[\r\n\t]/g, " ").trim().slice(0, 120) : "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function issueFromParts(repo: string, number: number, title: string) {
|
|
100
|
+
if (!REPO.test(repo) || !Number.isInteger(number) || number < 1 || number > 10_000_000) return null;
|
|
101
|
+
return { repo, number, title: clipTitle(title) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function oneIssue(item: unknown) {
|
|
105
|
+
if (typeof item === "string") {
|
|
106
|
+
const m = ISSUE_URL.exec(item.trim());
|
|
107
|
+
return m ? issueFromParts(`${m[1]}/${m[2]}`, Number(m[3]), "") : null;
|
|
108
|
+
}
|
|
109
|
+
if (!item || typeof item !== "object") return null;
|
|
110
|
+
const row = item as Record<string, unknown>;
|
|
111
|
+
if (typeof row.url === "string") {
|
|
112
|
+
const m = ISSUE_URL.exec(row.url.trim());
|
|
113
|
+
if (m) return issueFromParts(`${m[1]}/${m[2]}`, Number(m[3]), clipTitle(row.title));
|
|
114
|
+
}
|
|
115
|
+
const repo = typeof row.repo === "string" ? row.repo.trim() : "";
|
|
116
|
+
const number = typeof row.number === "number" ? row.number : Number(row.number);
|
|
117
|
+
return issueFromParts(repo, number, clipTitle(row.title));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeIssues(raw: unknown) {
|
|
121
|
+
if (!Array.isArray(raw) || raw.length > 8) return null;
|
|
122
|
+
const out: { repo: string; number: number; title: string }[] = [];
|
|
123
|
+
const seen = new Set<string>();
|
|
124
|
+
for (const item of raw) {
|
|
125
|
+
const issue = oneIssue(item);
|
|
126
|
+
if (!issue) return null;
|
|
127
|
+
const key = `${issue.repo}#${issue.number}`;
|
|
128
|
+
if (seen.has(key)) continue;
|
|
129
|
+
seen.add(key);
|
|
130
|
+
out.push(issue);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function storedIssues(raw: string | null) {
|
|
136
|
+
if (!raw) return [];
|
|
137
|
+
try {
|
|
138
|
+
const parsed = normalizeIssues(JSON.parse(raw));
|
|
139
|
+
return parsed ?? [];
|
|
140
|
+
} catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── 描述 / 子任务 ──
|
|
146
|
+
// 描述是 markdown 原文,整段替换。子任务是有序数组 [{id,text,done}]:PATCH 整个替换(排序、增删都走这里),
|
|
147
|
+
// 单项勾选走 PATCH /api/requirements/{id}/checklist/{itemId} {done} —— 只改那一项,app 和 Agent 同时操作
|
|
148
|
+
// 不会互相盖掉对方的整张清单。done 是显式值(不是「取反」),重复请求结果一样。
|
|
149
|
+
export const DESCRIPTION_MAX = 20_000;
|
|
150
|
+
export const CHECKLIST_MAX_ITEMS = 100;
|
|
151
|
+
export const CHECKLIST_TEXT_MAX = 500;
|
|
152
|
+
const CHECKLIST_ID = /^[A-Za-z0-9_-]{1,40}$/;
|
|
153
|
+
type ChecklistItem = { id: string; text: string; done: boolean };
|
|
154
|
+
|
|
155
|
+
function normalizeDescription(raw: unknown): string | null {
|
|
156
|
+
if (typeof raw !== "string") return null;
|
|
157
|
+
const text = raw.replace(/\r\n?/g, "\n");
|
|
158
|
+
return text.length > DESCRIPTION_MAX ? null : text;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 输入的 id 保留(客户端要靠它对上勾选);缺 id 的由 Hub 生成。重复 id、空文字、超长 → null(整体拒绝)。 */
|
|
162
|
+
function normalizeChecklist(raw: unknown): ChecklistItem[] | null {
|
|
163
|
+
if (!Array.isArray(raw) || raw.length > CHECKLIST_MAX_ITEMS) return null;
|
|
164
|
+
const seen = new Set<string>();
|
|
165
|
+
const out: ChecklistItem[] = [];
|
|
166
|
+
for (const value of raw) {
|
|
167
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
168
|
+
const item = value as Record<string, unknown>;
|
|
169
|
+
const id = item.id === undefined ? `ck_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}` : item.id;
|
|
170
|
+
if (typeof id !== "string" || !CHECKLIST_ID.test(id) || seen.has(id)) return null;
|
|
171
|
+
const text = typeof item.text === "string" ? item.text.replace(/[\r\n]+/g, " ").trim() : "";
|
|
172
|
+
if (!text || text.length > CHECKLIST_TEXT_MAX) return null;
|
|
173
|
+
if (item.done !== undefined && typeof item.done !== "boolean") return null;
|
|
174
|
+
seen.add(id);
|
|
175
|
+
out.push({ id, text, done: item.done === true });
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function storedChecklist(raw: string | null): ChecklistItem[] {
|
|
181
|
+
if (!raw) return [];
|
|
182
|
+
try { return normalizeChecklist(JSON.parse(raw)) ?? []; } catch { return []; }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── 项目 ──
|
|
186
|
+
// 按网络隔离;名字 1–40 字,同一网络里未归档的项目不重名;颜色 #RRGGBB;sort 越小越靠前。
|
|
187
|
+
// 删除 = 删项目行 + 把引用它的卡片 project_id 置空(卡片本身不动);归档 = 保留引用,只是不能再被选。
|
|
188
|
+
type ProjectRow = { project_id: string; network_id: string; name: string; color: string; sort: number; archived: number; created_at: string };
|
|
189
|
+
const PROJECT_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
190
|
+
const PROJECT_PALETTE = ["#2563eb", "#16a34a", "#d97706", "#dc2626", "#7c3aed", "#0891b2", "#db2777", "#4b5563"];
|
|
191
|
+
const PROJECT_SELECT = "project_id, network_id, name, color, sort, archived, created_at";
|
|
192
|
+
const projectPublic = (row: ProjectRow) => ({ id: row.project_id, name: row.name, color: row.color, sort: row.sort, archived: !!row.archived, createdAt: row.created_at });
|
|
193
|
+
const projectName = (raw: unknown): string | null => {
|
|
194
|
+
if (typeof raw !== "string") return null;
|
|
195
|
+
const name = raw.replace(/[\r\n\t]+/g, " ").trim();
|
|
196
|
+
return name && name.length <= 40 ? name : null;
|
|
197
|
+
};
|
|
198
|
+
function projectNameTaken(networkId: string, name: string, except?: string): boolean {
|
|
199
|
+
return !!db.get("SELECT 1 FROM requirement_projects WHERE network_id = ?1 AND name = ?2 AND archived = 0 AND project_id != ?3", networkId, name, except ?? "");
|
|
200
|
+
}
|
|
201
|
+
/** 卡片上的 project_id:null 清空;字符串必须是同一网络、未归档的项目。 */
|
|
202
|
+
function projectRef(value: unknown, networkId: string): string | null {
|
|
203
|
+
if (value === null) return null;
|
|
204
|
+
if (typeof value !== "string" || !value) throw new Error("invalid_project");
|
|
205
|
+
const row = db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, value);
|
|
206
|
+
if (!row || row.network_id !== networkId) throw new Error("project_not_in_network");
|
|
207
|
+
if (row.archived) throw new Error("project_archived");
|
|
208
|
+
return row.project_id;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function handleProjects(ctx: RequirementsRequestContext): Promise<Response> {
|
|
212
|
+
const { req, url } = ctx;
|
|
213
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
214
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
215
|
+
const one = url.pathname.match(/^\/api\/requirements\/projects\/([^/]+)$/);
|
|
216
|
+
if (url.pathname === "/api/requirements/projects" && req.method === "GET") {
|
|
217
|
+
const rows = db.all<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE network_id = ?1 ORDER BY sort, created_at`, networkId);
|
|
218
|
+
return Response.json({ ok: true, projects: rows.map(projectPublic) });
|
|
219
|
+
}
|
|
220
|
+
if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
221
|
+
let body: Record<string, unknown> = {};
|
|
222
|
+
if (req.method === "POST" || req.method === "PATCH") {
|
|
223
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
224
|
+
}
|
|
225
|
+
if (url.pathname === "/api/requirements/projects" && req.method === "POST") {
|
|
226
|
+
const name = projectName(body.name);
|
|
227
|
+
if (!name) return jsonError("invalid_project_name", 400);
|
|
228
|
+
if (projectNameTaken(networkId, name)) return jsonError("project_name_taken", 409);
|
|
229
|
+
const count = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirement_projects WHERE network_id = ?1", networkId)?.n ?? 0;
|
|
230
|
+
if (count >= 200) return jsonError("too_many_projects", 400);
|
|
231
|
+
const color = body.color === undefined ? PROJECT_PALETTE[count % PROJECT_PALETTE.length] : body.color;
|
|
232
|
+
if (typeof color !== "string" || !PROJECT_COLOR.test(color)) return jsonError("invalid_project_color", 400);
|
|
233
|
+
const sort = body.sort === undefined ? count : body.sort;
|
|
234
|
+
if (!Number.isInteger(sort)) return jsonError("invalid_project_sort", 400);
|
|
235
|
+
const id = `proj_${crypto.randomUUID()}`;
|
|
236
|
+
db.run(`INSERT INTO requirement_projects (${PROJECT_SELECT}) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6)`, [id, networkId, name, color, sort, new Date().toISOString()]);
|
|
237
|
+
return Response.json({ ok: true, project: projectPublic(db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, id)!) }, { status: 201 });
|
|
238
|
+
}
|
|
239
|
+
if (!one) return jsonError("not_found", 404);
|
|
240
|
+
const current = db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, decodeURIComponent(one[1]));
|
|
241
|
+
if (!current || current.network_id !== networkId) return jsonError("project_not_found", 404);
|
|
242
|
+
if (req.method === "DELETE") {
|
|
243
|
+
// 先把卡片上的引用置空,再删项目:卡片一张不少。
|
|
244
|
+
db.run("UPDATE requirements SET project_id = NULL WHERE project_id = ?1", [current.project_id]);
|
|
245
|
+
db.run("DELETE FROM requirement_projects WHERE project_id = ?1", [current.project_id]);
|
|
246
|
+
return Response.json({ ok: true });
|
|
247
|
+
}
|
|
248
|
+
if (req.method !== "PATCH") return jsonError("not_found", 404);
|
|
249
|
+
let { name, color, sort, archived } = current;
|
|
250
|
+
if ("name" in body) {
|
|
251
|
+
const next = projectName(body.name);
|
|
252
|
+
if (!next) return jsonError("invalid_project_name", 400);
|
|
253
|
+
name = next;
|
|
254
|
+
}
|
|
255
|
+
if ("color" in body) {
|
|
256
|
+
if (typeof body.color !== "string" || !PROJECT_COLOR.test(body.color)) return jsonError("invalid_project_color", 400);
|
|
257
|
+
color = body.color;
|
|
258
|
+
}
|
|
259
|
+
if ("sort" in body) {
|
|
260
|
+
if (!Number.isInteger(body.sort)) return jsonError("invalid_project_sort", 400);
|
|
261
|
+
sort = body.sort as number;
|
|
262
|
+
}
|
|
263
|
+
if ("archived" in body) {
|
|
264
|
+
if (typeof body.archived !== "boolean") return jsonError("invalid_project_archived", 400);
|
|
265
|
+
archived = body.archived ? 1 : 0;
|
|
266
|
+
}
|
|
267
|
+
if (!archived && projectNameTaken(networkId, name, current.project_id)) return jsonError("project_name_taken", 409);
|
|
268
|
+
db.run("UPDATE requirement_projects SET name = ?1, color = ?2, sort = ?3, archived = ?4 WHERE project_id = ?5", [name, color, sort, archived, current.project_id]);
|
|
269
|
+
return Response.json({ ok: true, project: projectPublic(db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, current.project_id)!) });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function toPublic(row: Row) {
|
|
273
|
+
return {
|
|
274
|
+
owner: row.owner_json ? JSON.parse(row.owner_json) : null,
|
|
275
|
+
// 负责 Agent(执行者)。字段总在:客户端靠它判断这个 Hub 分不分「负责人 / 负责 Agent」。
|
|
276
|
+
agent_owner: row.agent_owner_json ? JSON.parse(row.agent_owner_json) : null,
|
|
277
|
+
// 描述(markdown)与子任务。字段总在:客户端靠它判断这个 Hub 支不支持。
|
|
278
|
+
description: row.description || "",
|
|
279
|
+
checklist: storedChecklist(row.checklist_json),
|
|
280
|
+
// 项目(可空)。字段总在:客户端靠它判断这个 Hub 有没有项目。项目被删 → 置空;被归档 → 引用保留。
|
|
281
|
+
project_id: row.project_id || null,
|
|
282
|
+
participants: JSON.parse(row.participants_json || '[]'),
|
|
283
|
+
id: row.requirement_id,
|
|
284
|
+
name: row.title,
|
|
285
|
+
priority: row.priority,
|
|
286
|
+
assignee: row.assignee || "",
|
|
287
|
+
due: row.due_on || "",
|
|
288
|
+
column: row.column_name,
|
|
289
|
+
createdAt: row.created_at,
|
|
290
|
+
updatedAt: row.updated_at || row.created_at,
|
|
291
|
+
issues: storedIssues(row.issues_json),
|
|
292
|
+
tags: storedTags(row.tags_json),
|
|
293
|
+
// 同步用的外部引用(如 github:owner/repo#123)和链接;归档的卡默认不在列表里(include_archived=1 才有)。
|
|
294
|
+
external_ref: row.external_ref || null,
|
|
295
|
+
external_url: row.external_url || null,
|
|
296
|
+
archived: !!row.archived,
|
|
297
|
+
// 谁建的 / 谁最后改的:{kind:"user"|"node", id}。旧卡只有 created_by(用户 id)。
|
|
298
|
+
created_by: row.created_by_json ? JSON.parse(row.created_by_json) : row.created_by ? { kind: "user", id: row.created_by } : null,
|
|
299
|
+
updated_by: row.updated_by_json ? JSON.parse(row.updated_by_json) : null,
|
|
300
|
+
// 子需求:parent_id(可空)和父卡上的子需求进度(未归档的子需求数 / 其中完成的)。
|
|
301
|
+
parent_id: row.parent_id || null,
|
|
302
|
+
children: { total: Number(row.children_total ?? 0), done: Number(row.children_done ?? 0) },
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function bodyObject(req: Request): Promise<Record<string, unknown>> {
|
|
307
|
+
const value = await req.json();
|
|
308
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_json");
|
|
309
|
+
return value as Record<string, unknown>;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function writeNetwork(body: Record<string, unknown>, ctx: RequirementsRequestContext): string | null {
|
|
313
|
+
if (ctx.auth?.networkId) return ctx.auth.networkId;
|
|
314
|
+
if (typeof body.network_id === "string" && body.network_id.trim()) return body.network_id.trim();
|
|
315
|
+
return resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function canWrite(ctx: RequirementsRequestContext, networkId: string | null): boolean {
|
|
319
|
+
// 节点令牌只能写它绑定的那个网络(scope 已强制;这里再核一次,防止别的入口绕过 scope)。
|
|
320
|
+
if (ctx.isNodeToken && (!ctx.auth?.networkId || ctx.auth.networkId !== networkId)) return false;
|
|
321
|
+
if (ctx.isNodeToken) return canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
|
|
322
|
+
// 需求看板是人类协作面:受限成员(只看授权 Agent 的成员)照样能读写卡片,
|
|
323
|
+
// 只是卡片上没授权给他的 Agent 引用会被隐去、也不能被他指派(见 hiddenNodeFilter)。
|
|
324
|
+
return canRestWriteNetworkAsHuman(ctx.auth, networkId, ctx.isAdmin);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── 多用户 Agent 权限 ──
|
|
328
|
+
// 受限成员看需求卡时:agent_owner / 参与人 / created_by / updated_by 里没授权给他的节点一律隐去;
|
|
329
|
+
// 他写卡时也不能引用这些节点。返回 null = 这个调用者在这个网络里不受限。
|
|
330
|
+
type HiddenNode = ((nodeId: string) => boolean) | null;
|
|
331
|
+
function hiddenNodeFilter(ctx: RequirementsRequestContext, networkId: string): HiddenNode {
|
|
332
|
+
if (!ctx.auth || ctx.isNodeToken || ctx.isAdmin) return null;
|
|
333
|
+
if (!isAgentRestricted(ctx.auth.userId, networkId)) return null;
|
|
334
|
+
const visible = new Set(visibleAgents(ctx.auth.userId, networkId).nodeIds);
|
|
335
|
+
return (nodeId: string) => !visible.has(nodeId);
|
|
336
|
+
}
|
|
337
|
+
function isHiddenRef(ref: unknown, hidden: HiddenNode): boolean {
|
|
338
|
+
if (!hidden || !ref || typeof ref !== "object") return false;
|
|
339
|
+
const r = ref as { kind?: unknown; id?: unknown };
|
|
340
|
+
return r.kind === "node" && typeof r.id === "string" && hidden(r.id);
|
|
341
|
+
}
|
|
342
|
+
function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
|
|
343
|
+
const pub = toPublic(row);
|
|
344
|
+
const hidden = hiddenNodeFilter(ctx, row.network_id);
|
|
345
|
+
if (!hidden) return pub;
|
|
346
|
+
return {
|
|
347
|
+
...pub,
|
|
348
|
+
owner: isHiddenRef(pub.owner, hidden) ? null : pub.owner,
|
|
349
|
+
agent_owner: isHiddenRef(pub.agent_owner, hidden) ? null : pub.agent_owner,
|
|
350
|
+
participants: Array.isArray(pub.participants) ? pub.participants.filter((ref: unknown) => !isHiddenRef(ref, hidden)) : pub.participants,
|
|
351
|
+
created_by: isHiddenRef(pub.created_by, hidden) ? null : pub.created_by,
|
|
352
|
+
updated_by: isHiddenRef(pub.updated_by, hidden) ? null : pub.updated_by,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
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, " +
|
|
357
|
+
"(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
|
|
358
|
+
"(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";
|
|
359
|
+
|
|
360
|
+
type PersonRef = { kind: 'user' | 'node'; id: string };
|
|
361
|
+
function personRef(value: unknown, networkId: string, hidden: HiddenNode = null): PersonRef {
|
|
362
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid_person');
|
|
363
|
+
const row = value as Record<string, unknown>;
|
|
364
|
+
if ((row.kind !== 'user' && row.kind !== 'node') || typeof row.id !== 'string' || !row.id.trim()) throw new Error('invalid_person');
|
|
365
|
+
const exists = row.kind === 'user'
|
|
366
|
+
? 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)
|
|
367
|
+
: db.get("SELECT 1 FROM nodes WHERE network_id=?1 AND node_id=?2", networkId, row.id);
|
|
368
|
+
// 没授权的节点与不存在的节点同一个错误,不给受限成员留 node_id 探测差异。
|
|
369
|
+
if (!exists || (row.kind === 'node' && hidden?.(row.id as string))) throw new Error('person_not_in_network');
|
|
370
|
+
return { kind: row.kind, id: row.id };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 负责人只能是人类,负责 Agent 只能是节点;参与人两种都行。库里旧的节点负责人照样读出来(启动迁移会挪走),
|
|
374
|
+
// 只有这次请求显式写了 owner / agent_owner 才校验种类。
|
|
375
|
+
// 旧客户端兼容(App ≤ 0.2.142 只有一个「负责人」,可以选节点):请求里 owner 是节点、又**没带** agent_owner
|
|
376
|
+
// → 当成设置负责 Agent,负责人清空,照常 200,并回 owner_coerced_to_agent_owner: true。
|
|
377
|
+
// 带了 agent_owner 的就是新客户端,保持严格:owner 是节点 → 400 owner_must_be_human。
|
|
378
|
+
function assignments(body: Record<string, unknown>, networkId: string, current?: Row, hidden: HiddenNode = null) {
|
|
379
|
+
let owner = current?.owner_json ? JSON.parse(current.owner_json) : null;
|
|
380
|
+
let agentOwner = current?.agent_owner_json ? JSON.parse(current.agent_owner_json) : null;
|
|
381
|
+
let coerced: PersonRef | null = null;
|
|
382
|
+
// 受限成员看不见的负责 Agent,他也不能换掉或清空(他读到的是 null,写回 null 不该抹掉别人的指派)。
|
|
383
|
+
const agentOwnerLocked = isHiddenRef(agentOwner, hidden);
|
|
384
|
+
if ('owner' in body) {
|
|
385
|
+
owner = body.owner === null ? null : personRef(body.owner, networkId, hidden);
|
|
386
|
+
if (owner && owner.kind !== 'user') {
|
|
387
|
+
if ('agent_owner' in body) throw new Error('owner_must_be_human');
|
|
388
|
+
coerced = owner;
|
|
389
|
+
agentOwner = owner;
|
|
390
|
+
owner = null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (coerced && agentOwnerLocked) throw new Error('agent_owner_not_granted');
|
|
394
|
+
if ('agent_owner' in body) {
|
|
395
|
+
if (agentOwnerLocked) throw new Error('agent_owner_not_granted');
|
|
396
|
+
agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId, hidden);
|
|
397
|
+
if (agentOwner && agentOwner.kind !== 'node') throw new Error('agent_owner_must_be_agent');
|
|
398
|
+
}
|
|
399
|
+
let participants = current ? JSON.parse(current.participants_json || '[]') : [];
|
|
400
|
+
if ('participants' in body) {
|
|
401
|
+
if (!Array.isArray(body.participants) || body.participants.length > 100) throw new Error('invalid_participants');
|
|
402
|
+
const refs = body.participants.map(value => personRef(value, networkId, hidden));
|
|
403
|
+
// 参与人是整体替换:把受限成员看不见的那些节点原样留下,别让他「保存」时静默删掉。
|
|
404
|
+
const keptHidden = hidden ? participants.filter((ref: unknown) => isHiddenRef(ref, hidden)) : [];
|
|
405
|
+
participants = [...new Map([...keptHidden, ...refs].map((ref: PersonRef) => [`${ref.kind}:${ref.id}`, ref])).values()];
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
coerced,
|
|
409
|
+
ownerJson: owner === null ? null : JSON.stringify(owner),
|
|
410
|
+
agentOwnerJson: agentOwner === null ? null : JSON.stringify(agentOwner),
|
|
411
|
+
participantsJson: JSON.stringify(participants),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 节点令牌(Agent,比如把 GitHub issue 同步进任务的 TM 节点)可用的操作 —— 只在它自己的网络里
|
|
417
|
+
* (令牌绑定的网络,范围由 resolveRestNetworkScope 强制)。删除卡片、管理项目仍然只给人。
|
|
418
|
+
*/
|
|
419
|
+
const NODE_TOKEN_OPERATIONS: ReadonlySet<string> = new Set<string>(["read", "create", "patch", "checklist_item", "upsert", "projects_read"]);
|
|
420
|
+
function operationOf(req: Request, url: URL): string {
|
|
421
|
+
if (/^\/api\/requirements\/projects(\/|$)/.test(url.pathname)) return req.method === "GET" ? "projects_read" : "projects_write";
|
|
422
|
+
if (req.method === "GET") return "read";
|
|
423
|
+
if (/^\/api\/requirements\/[^/]+\/checklist\/[^/]+$/.test(url.pathname)) return "checklist_item";
|
|
424
|
+
if (url.pathname === "/api/requirements/upsert") return "upsert";
|
|
425
|
+
if (req.method === "DELETE") return "delete";
|
|
426
|
+
if (req.method === "PATCH") return "patch";
|
|
427
|
+
return "create";
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest"] as const;
|
|
431
|
+
|
|
432
|
+
// ── 子需求 ──
|
|
433
|
+
// parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
|
|
434
|
+
export const MAX_REQUIREMENT_DEPTH = 5;
|
|
435
|
+
function levelOf(id: string): number {
|
|
436
|
+
let level = 1;
|
|
437
|
+
let cur: string | null = id;
|
|
438
|
+
const seen = new Set<string>();
|
|
439
|
+
while (cur) {
|
|
440
|
+
if (seen.has(cur)) return Number.POSITIVE_INFINITY; // 已存在的环(不该有):当作超深,拒绝挂上去
|
|
441
|
+
seen.add(cur);
|
|
442
|
+
const up = db.get<{ parent_id: string | null }>("SELECT parent_id FROM requirements WHERE requirement_id = ?1", cur);
|
|
443
|
+
cur = up?.parent_id ?? null;
|
|
444
|
+
if (cur) level++;
|
|
445
|
+
}
|
|
446
|
+
return level;
|
|
447
|
+
}
|
|
448
|
+
/** 以 id 为根的子树还有几层(叶子 = 0)。 */
|
|
449
|
+
function heightBelow(id: string): number {
|
|
450
|
+
let height = 0;
|
|
451
|
+
let frontier = [id];
|
|
452
|
+
const seen = new Set<string>([id]);
|
|
453
|
+
while (frontier.length && height <= MAX_REQUIREMENT_DEPTH) {
|
|
454
|
+
const next: string[] = [];
|
|
455
|
+
for (const f of frontier) {
|
|
456
|
+
for (const c of db.all<{ requirement_id: string }>("SELECT requirement_id FROM requirements WHERE parent_id = ?1", f)) {
|
|
457
|
+
if (!seen.has(c.requirement_id)) { seen.add(c.requirement_id); next.push(c.requirement_id); }
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (!next.length) break;
|
|
461
|
+
height++;
|
|
462
|
+
frontier = next;
|
|
463
|
+
}
|
|
464
|
+
return height;
|
|
465
|
+
}
|
|
466
|
+
/** 校验要挂的父卡:同网络、存在、不是自己或自己的后代(不成环)、挂上后不超过 5 层。返回错误码或 null。 */
|
|
467
|
+
function parentError(networkId: string, parentId: string, selfId: string | null): string | null {
|
|
468
|
+
const parent = db.get<{ network_id: string; requirement_id: string }>("SELECT network_id, requirement_id FROM requirements WHERE requirement_id = ?1", parentId);
|
|
469
|
+
if (!parent || parent.network_id !== networkId) return "parent_not_found";
|
|
470
|
+
if (selfId) {
|
|
471
|
+
// 从父卡往上走,碰到自己 = 成环
|
|
472
|
+
let cur: string | null = parentId;
|
|
473
|
+
const seen = new Set<string>();
|
|
474
|
+
while (cur && !seen.has(cur)) {
|
|
475
|
+
if (cur === selfId) return "parent_cycle";
|
|
476
|
+
seen.add(cur);
|
|
477
|
+
cur = db.get<{ parent_id: string | null }>("SELECT parent_id FROM requirements WHERE requirement_id = ?1", cur)?.parent_id ?? null;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const depth = levelOf(parentId) + 1 + (selfId ? heightBelow(selfId) : 0);
|
|
481
|
+
return depth > MAX_REQUIREMENT_DEPTH ? "parent_too_deep" : null;
|
|
482
|
+
}
|
|
483
|
+
function parentIdOf(value: unknown): string | null | undefined {
|
|
484
|
+
if (value === null || value === "") return null;
|
|
485
|
+
return typeof value === "string" && /^[A-Za-z0-9_-]{1,200}$/.test(value) ? value : undefined;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── 谁做的 ──
|
|
489
|
+
// 用户令牌 = {kind:"user", id:user_id};节点令牌 = {kind:"node", id:令牌绑定的 node_id}(老令牌没绑 node_id 时按
|
|
490
|
+
// 令牌名里的 alias 在本网络找;都找不到记 token 本身,不冒充任何节点)。写进 created_by / updated_by。
|
|
491
|
+
type Actor = { kind: "user" | "node"; id: string };
|
|
492
|
+
function actorOf(ctx: RequirementsRequestContext): Actor | null {
|
|
493
|
+
if (!ctx.auth) return null;
|
|
494
|
+
if (!ctx.isNodeToken) return { kind: "user", id: ctx.auth.userId };
|
|
495
|
+
const tokenId = ctx.auth.tokenId ?? null;
|
|
496
|
+
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;
|
|
497
|
+
if (bound) return { kind: "node", id: bound };
|
|
498
|
+
const alias = ctx.auth.tokenName?.startsWith("node:") ? ctx.auth.tokenName.slice(5) : null;
|
|
499
|
+
if (alias && ctx.auth.networkId) {
|
|
500
|
+
const node = db.get<{ node_id: string }>("SELECT node_id FROM nodes WHERE network_id = ?1 AND alias = ?2", ctx.auth.networkId, alias);
|
|
501
|
+
if (node) return { kind: "node", id: node.node_id };
|
|
502
|
+
}
|
|
503
|
+
return { kind: "node", id: `token:${tokenId ?? "unknown"}` };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ── 外部引用(同步用) ──
|
|
507
|
+
// external_ref:如 github:owner/repo#123,同一网络里唯一(部分唯一索引,见 requirements-migrate.ts)。
|
|
508
|
+
// external_url:http(s) 链接,给界面显示成「在 GitHub 打开」。
|
|
509
|
+
const EXTERNAL_REF = /^[A-Za-z0-9][A-Za-z0-9_.:\/#@+-]{0,199}$/;
|
|
510
|
+
function externalRef(value: unknown): string | null | undefined {
|
|
511
|
+
if (value === null) return null;
|
|
512
|
+
if (typeof value !== "string") return undefined;
|
|
513
|
+
const v = value.trim();
|
|
514
|
+
return EXTERNAL_REF.test(v) ? v : undefined;
|
|
515
|
+
}
|
|
516
|
+
function externalUrl(value: unknown): string | null | undefined {
|
|
517
|
+
if (value === null || value === "") return null;
|
|
518
|
+
if (typeof value !== "string") return undefined;
|
|
519
|
+
const v = value.trim();
|
|
520
|
+
return /^https?:\/\/[^\s]{1,500}$/i.test(v) ? v : undefined;
|
|
521
|
+
}
|
|
522
|
+
function rowByExternalRef(networkId: string, ref: string): Row | undefined {
|
|
523
|
+
return db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND external_ref = ?2`, networkId, ref) ?? undefined;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ── 新建 ──
|
|
527
|
+
async function createRequirement(ctx: RequirementsRequestContext, body: Record<string, unknown>): Promise<Response> {
|
|
528
|
+
const networkId = writeNetwork(body, ctx);
|
|
529
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
530
|
+
if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
531
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
532
|
+
if (!name || name.length > 80) return jsonError("invalid_name", 400);
|
|
533
|
+
const priority = typeof body.priority === "string" ? body.priority : "normal";
|
|
534
|
+
if (!PRIORITIES.has(priority)) return jsonError("invalid_priority", 400);
|
|
535
|
+
const due = typeof body.due === "string" ? normalizeDue(body.due) : "";
|
|
536
|
+
if (due === null) return jsonError("invalid_due", 400);
|
|
537
|
+
const assignee = typeof body.assignee === "string" ? body.assignee.trim().slice(0, 80) : "";
|
|
538
|
+
const clientId = typeof body.client_id === "string" ? body.client_id.trim() : "";
|
|
539
|
+
if (clientId && !/^[A-Za-z0-9._-]{1,80}$/.test(clientId)) return jsonError("invalid_client_id", 400);
|
|
540
|
+
const column = typeof body.column === "string" && COLUMNS.has(body.column) ? body.column : "pool";
|
|
541
|
+
const issues = body.issues === undefined ? [] : normalizeIssues(body.issues);
|
|
542
|
+
if (issues === null) return jsonError("invalid_issues", 400);
|
|
543
|
+
const tags = body.tags === undefined ? [] : normalizeTags(body.tags);
|
|
544
|
+
if (tags === null) return jsonError("invalid_tags", 400);
|
|
545
|
+
const description = body.description === undefined ? "" : normalizeDescription(body.description);
|
|
546
|
+
if (description === null) return jsonError("invalid_description", 400);
|
|
547
|
+
const checklist = body.checklist === undefined ? [] : normalizeChecklist(body.checklist);
|
|
548
|
+
if (checklist === null) return jsonError("invalid_checklist", 400);
|
|
549
|
+
let projectId: string | null = null;
|
|
550
|
+
if (body.project_id !== undefined) {
|
|
551
|
+
try { projectId = projectRef(body.project_id, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
552
|
+
}
|
|
553
|
+
const ref = body.external_ref === undefined ? null : externalRef(body.external_ref);
|
|
554
|
+
if (ref === undefined) return jsonError("invalid_external_ref", 400);
|
|
555
|
+
const extUrl = body.external_url === undefined ? null : externalUrl(body.external_url);
|
|
556
|
+
if (extUrl === undefined) return jsonError("invalid_external_url", 400);
|
|
557
|
+
const parentId = body.parent_id === undefined ? null : parentIdOf(body.parent_id);
|
|
558
|
+
if (parentId === undefined) return jsonError("invalid_parent_id", 400);
|
|
559
|
+
if (parentId) {
|
|
560
|
+
const err = parentError(networkId, parentId, null);
|
|
561
|
+
if (err) return jsonError(err, 400);
|
|
562
|
+
}
|
|
563
|
+
if (ref) {
|
|
564
|
+
const existing = rowByExternalRef(networkId, ref);
|
|
565
|
+
// 同一个外部条目再建一次 = 冲突,回已有的 id(同步方改用 upsert,或按这个 id PATCH)。
|
|
566
|
+
if (existing) return Response.json({ ok: false, error: "external_ref_exists", existing_id: existing.requirement_id }, { status: 409 });
|
|
567
|
+
}
|
|
568
|
+
if (clientId) {
|
|
569
|
+
const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
|
|
570
|
+
if (existing) return Response.json({ ok: true, requirement: toPublicFor(ctx, existing) });
|
|
571
|
+
}
|
|
572
|
+
const id = `req_${crypto.randomUUID()}`;
|
|
573
|
+
const createdAt = new Date().toISOString();
|
|
574
|
+
let people;
|
|
575
|
+
try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId)); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
576
|
+
const actor = JSON.stringify(actorOf(ctx));
|
|
577
|
+
try {
|
|
578
|
+
db.run(
|
|
579
|
+
`INSERT INTO requirements
|
|
580
|
+
(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)
|
|
581
|
+
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)`,
|
|
582
|
+
[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)],
|
|
583
|
+
);
|
|
584
|
+
} catch {
|
|
585
|
+
// 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
|
|
586
|
+
if (ref) {
|
|
587
|
+
const existing = rowByExternalRef(networkId, ref);
|
|
588
|
+
if (existing) return Response.json({ ok: false, error: "external_ref_exists", existing_id: existing.requirement_id }, { status: 409 });
|
|
589
|
+
}
|
|
590
|
+
if (!clientId) return jsonError("insert_failed", 500);
|
|
591
|
+
const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
|
|
592
|
+
if (!existing) return jsonError("insert_failed", 500);
|
|
593
|
+
return Response.json({ ok: true, requirement: toPublicFor(ctx, existing) });
|
|
594
|
+
}
|
|
595
|
+
const created = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, id)!;
|
|
596
|
+
return Response.json(withLegacyOwner(toPublicFor(ctx, created), people.coerced), { status: 201 });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ── 修改(省略的字段保留原值) ──
|
|
600
|
+
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"];
|
|
601
|
+
function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Record<string, unknown>): Response {
|
|
602
|
+
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
|
|
603
|
+
const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
|
|
604
|
+
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
605
|
+
if (has("column") && !COLUMNS.has(String(body.column))) return jsonError("invalid_column", 400);
|
|
606
|
+
const issues = has("issues") ? normalizeIssues(body.issues) : null;
|
|
607
|
+
if (has("issues") && issues === null) return jsonError("invalid_issues", 400);
|
|
608
|
+
const tags = has("tags") ? normalizeTags(body.tags) : null;
|
|
609
|
+
if (has("tags") && tags === null) return jsonError("invalid_tags", 400);
|
|
610
|
+
const description = has("description") ? normalizeDescription(body.description) : null;
|
|
611
|
+
if (has("description") && description === null) return jsonError("invalid_description", 400);
|
|
612
|
+
const checklist = has("checklist") ? normalizeChecklist(body.checklist) : null;
|
|
613
|
+
if (has("checklist") && checklist === null) return jsonError("invalid_checklist", 400);
|
|
614
|
+
let name = row.title;
|
|
615
|
+
if (has("name")) {
|
|
616
|
+
name = typeof body.name === "string" ? body.name.trim() : "";
|
|
617
|
+
if (!name || name.length > 80) return jsonError("invalid_name", 400);
|
|
618
|
+
}
|
|
619
|
+
let priority = row.priority;
|
|
620
|
+
if (has("priority")) {
|
|
621
|
+
if (typeof body.priority !== "string" || !PRIORITIES.has(body.priority)) return jsonError("invalid_priority", 400);
|
|
622
|
+
priority = body.priority;
|
|
623
|
+
}
|
|
624
|
+
let due = row.due_on || "";
|
|
625
|
+
if (has("due")) {
|
|
626
|
+
if (typeof body.due !== "string") return jsonError("invalid_due", 400);
|
|
627
|
+
const next = normalizeDue(body.due);
|
|
628
|
+
if (next === null) return jsonError("invalid_due", 400);
|
|
629
|
+
due = next;
|
|
630
|
+
}
|
|
631
|
+
let assignee = row.assignee || "";
|
|
632
|
+
if (has("assignee")) {
|
|
633
|
+
if (typeof body.assignee !== "string") return jsonError("invalid_assignee", 400);
|
|
634
|
+
assignee = body.assignee.trim().slice(0, 80);
|
|
635
|
+
}
|
|
636
|
+
let people;
|
|
637
|
+
try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id)); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
638
|
+
let projectId = row.project_id;
|
|
639
|
+
if (has("project_id")) {
|
|
640
|
+
try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
641
|
+
}
|
|
642
|
+
let ref = row.external_ref;
|
|
643
|
+
if (has("external_ref")) {
|
|
644
|
+
const next = externalRef(body.external_ref);
|
|
645
|
+
if (next === undefined) return jsonError("invalid_external_ref", 400);
|
|
646
|
+
if (next && next !== row.external_ref) {
|
|
647
|
+
const taken = rowByExternalRef(row.network_id, next);
|
|
648
|
+
if (taken) return Response.json({ ok: false, error: "external_ref_exists", existing_id: taken.requirement_id }, { status: 409 });
|
|
649
|
+
}
|
|
650
|
+
ref = next;
|
|
651
|
+
}
|
|
652
|
+
let extUrl = row.external_url;
|
|
653
|
+
if (has("external_url")) {
|
|
654
|
+
const next = externalUrl(body.external_url);
|
|
655
|
+
if (next === undefined) return jsonError("invalid_external_url", 400);
|
|
656
|
+
extUrl = next;
|
|
657
|
+
}
|
|
658
|
+
let parentId = row.parent_id;
|
|
659
|
+
if (has("parent_id")) {
|
|
660
|
+
const next = parentIdOf(body.parent_id);
|
|
661
|
+
if (next === undefined) return jsonError("invalid_parent_id", 400);
|
|
662
|
+
if (next) {
|
|
663
|
+
const err = parentError(row.network_id, next, row.requirement_id);
|
|
664
|
+
if (err) return jsonError(err, 400);
|
|
665
|
+
}
|
|
666
|
+
parentId = next;
|
|
667
|
+
}
|
|
668
|
+
let archived = row.archived ? 1 : 0;
|
|
669
|
+
if (has("archived")) {
|
|
670
|
+
if (typeof body.archived !== "boolean") return jsonError("invalid_archived", 400);
|
|
671
|
+
archived = body.archived ? 1 : 0;
|
|
672
|
+
}
|
|
673
|
+
const updatedAt = new Date().toISOString();
|
|
674
|
+
db.run(
|
|
675
|
+
`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,
|
|
676
|
+
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
|
|
677
|
+
WHERE requirement_id = ?3`,
|
|
678
|
+
[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,
|
|
679
|
+
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],
|
|
680
|
+
);
|
|
681
|
+
const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
682
|
+
return Response.json(withLegacyOwner(toPublicFor(ctx, updated), people.coerced));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* 被兼容改写的请求(旧客户端):响应里的 owner 按旧客户端的理解回显那个节点(它会拿响应核对「保存生效没有」,
|
|
687
|
+
* 0.2.142 的核对不通过就报「这个 Hub 还不能修改」),真实存储是 owner 空、agent_owner = 节点;agent_owner 也一并返回。
|
|
688
|
+
* GET 不这么做:新 App 读 owner / agent_owner 两个字段,GET 回显会让新 App 把 Agent 当成负责人。
|
|
689
|
+
*/
|
|
690
|
+
function withLegacyOwner(requirement: ReturnType<typeof toPublic>, coerced: PersonRef | null) {
|
|
691
|
+
if (!coerced) return { ok: true, requirement };
|
|
692
|
+
return { ok: true, requirement: { ...requirement, owner: coerced }, owner_coerced_to_agent_owner: true };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function scopedRow(ctx: RequirementsRequestContext, id: string): Row | undefined {
|
|
696
|
+
const params: unknown[] = [id];
|
|
697
|
+
let sql = `SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`;
|
|
698
|
+
sql = addHumanNetworkScope(sql, params, ctx.scope);
|
|
699
|
+
return db.get<Row>(sql, ...params) ?? undefined;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** GET 的筛选(同步方 / MCP 用):status、project_id、owner / agent_owner(kind:id)、updated_since、external_ref、parent_id / top_level、include_archived。 */
|
|
703
|
+
function listFilters(url: URL, sql: string, params: unknown[], ctx?: RequirementsRequestContext): string | Response {
|
|
704
|
+
const q = url.searchParams;
|
|
705
|
+
const status = q.get("status");
|
|
706
|
+
if (status !== null) {
|
|
707
|
+
if (!COLUMNS.has(status)) return jsonError("invalid_status", 400);
|
|
708
|
+
sql += ` AND column_name = ?${params.push(status)}`;
|
|
709
|
+
}
|
|
710
|
+
const project = q.get("project_id");
|
|
711
|
+
if (project !== null) sql += project === "none" ? " AND project_id IS NULL" : ` AND project_id = ?${params.push(project)}`;
|
|
712
|
+
for (const [param, column] of [["owner", "owner_json"], ["agent_owner", "agent_owner_json"]] as const) {
|
|
713
|
+
const v = q.get(param);
|
|
714
|
+
if (v === null) continue;
|
|
715
|
+
if (v === "none") { sql += ` AND ${column} IS NULL`; continue; }
|
|
716
|
+
const m = /^(user|node):(.+)$/.exec(v);
|
|
717
|
+
if (!m) return jsonError(`invalid_${param}`, 400);
|
|
718
|
+
// 受限成员按一个没授权给他的节点筛选:与「没有这样的卡」同一结果(否则是「谁负责这张卡」的探测器)。
|
|
719
|
+
if (m[1] === "node" && ctx && hiddenFilterForScope(ctx, m[2])) { sql += " AND 1=0"; continue; }
|
|
720
|
+
sql += ` AND ${column} = ?${params.push(JSON.stringify({ kind: m[1], id: m[2] }))}`;
|
|
721
|
+
}
|
|
722
|
+
const since = q.get("updated_since");
|
|
723
|
+
if (since !== null) {
|
|
724
|
+
const ms = Date.parse(since);
|
|
725
|
+
if (!Number.isFinite(ms)) return jsonError("invalid_updated_since", 400);
|
|
726
|
+
sql += ` AND updated_at >= ?${params.push(new Date(ms).toISOString())}`;
|
|
727
|
+
}
|
|
728
|
+
const ref = q.get("external_ref");
|
|
729
|
+
if (ref !== null) sql += ` AND external_ref = ?${params.push(ref)}`;
|
|
730
|
+
const parent = q.get("parent_id");
|
|
731
|
+
if (parent !== null) sql += parent === "none" ? " AND parent_id IS NULL" : ` AND parent_id = ?${params.push(parent)}`;
|
|
732
|
+
if (q.get("top_level") === "1") sql += " AND parent_id IS NULL";
|
|
733
|
+
// Explicit archived-only wins over the legacy include-all switch.
|
|
734
|
+
if (q.get("archived") === "true") sql += " AND COALESCE(archived, 0) = 1";
|
|
735
|
+
else if (q.get("include_archived") !== "1") sql += " AND COALESCE(archived, 0) = 0";
|
|
736
|
+
return sql;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** 这个节点对调用者在他作用域内的某个受限网络里是否隐藏。 */
|
|
740
|
+
function hiddenFilterForScope(ctx: RequirementsRequestContext, nodeId: string): boolean {
|
|
741
|
+
const restricted = ctx.scope.agentRestriction?.networkIds ?? [];
|
|
742
|
+
for (const networkId of restricted) {
|
|
743
|
+
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
744
|
+
if (hidden?.(nodeId)) return true;
|
|
745
|
+
}
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export async function handleRequirementsRequest(ctx: RequirementsRequestContext): Promise<Response | null> {
|
|
750
|
+
const { req, url } = ctx;
|
|
751
|
+
if (url.pathname !== "/api/requirements" && !url.pathname.startsWith("/api/requirements/")) return null;
|
|
752
|
+
// 节点令牌只能做 NODE_TOKEN_OPERATIONS 里的事(读 / 建 / 改 / 勾子任务 / upsert / 读项目),
|
|
753
|
+
// 而且只在它绑定的网络里 —— 范围由 scope 强制,写入再由 canWrite 核一次。
|
|
754
|
+
if (ctx.isNodeToken && !NODE_TOKEN_OPERATIONS.has(operationOf(req, url))) return jsonError("user_token_required", 403);
|
|
755
|
+
|
|
756
|
+
if (url.pathname === "/api/requirements/projects" || url.pathname.startsWith("/api/requirements/projects/")) return handleProjects(ctx);
|
|
757
|
+
|
|
758
|
+
if (url.pathname === "/api/requirements/tags" && req.method === "GET") {
|
|
759
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
760
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
761
|
+
const rows = db.all<{ tags_json: string | null }>("SELECT tags_json FROM requirements WHERE network_id=?1", networkId);
|
|
762
|
+
const tags = [...new Set(rows.flatMap(row => storedTags(row.tags_json)))].sort();
|
|
763
|
+
return Response.json({ ok: true, networkId, tags });
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
|
|
767
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
768
|
+
if (!networkId) return jsonError('network_id_required', 400);
|
|
769
|
+
const users = db.all<{ id: string; name: string }>(
|
|
770
|
+
"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,
|
|
771
|
+
);
|
|
772
|
+
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
773
|
+
const nodes = db.all<{ id: string; name: string }>(
|
|
774
|
+
"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,
|
|
775
|
+
).filter(row => !hidden?.(row.id));
|
|
776
|
+
return Response.json({ ok: true, people: [...users.map(row => ({ ...row, kind: 'user', networkId })), ...nodes.map(row => ({ ...row, kind: 'node', networkId }))] });
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (url.pathname === "/api/requirements" && req.method === "GET") {
|
|
780
|
+
const params: unknown[] = [];
|
|
781
|
+
let sql = `SELECT ${SELECT} FROM requirements WHERE 1=1`;
|
|
782
|
+
sql = addHumanNetworkScope(sql, params, ctx.scope);
|
|
783
|
+
const filtered = listFilters(url, sql, params, ctx);
|
|
784
|
+
if (typeof filtered !== "string") return filtered;
|
|
785
|
+
const rows = db.all<Row>(`${filtered} ORDER BY created_at DESC LIMIT 500`, ...params).map(row => toPublicFor(ctx, row));
|
|
786
|
+
// capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
|
|
787
|
+
return Response.json({ ok: true, requirements: rows, capabilities: REQUIREMENT_CAPABILITIES });
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (url.pathname === "/api/requirements" && req.method === "POST") {
|
|
791
|
+
let body: Record<string, unknown>;
|
|
792
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
793
|
+
return createRequirement(ctx, body);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// 按 external_ref 建或改(同步用,幂等):同一网络里已有这个 external_ref → 按 PATCH 语义改它
|
|
797
|
+
// (省略的字段保留,包括状态);没有 → 新建。重复同步同一个 issue 只会改,不会重复建。
|
|
798
|
+
if (url.pathname === "/api/requirements/upsert" && req.method === "POST") {
|
|
799
|
+
let body: Record<string, unknown>;
|
|
800
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
801
|
+
const ref = externalRef(body.external_ref);
|
|
802
|
+
if (!ref) return jsonError("external_ref_required", 400);
|
|
803
|
+
const networkId = writeNetwork(body, ctx);
|
|
804
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
805
|
+
if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
806
|
+
const existing = rowByExternalRef(networkId, ref);
|
|
807
|
+
if (!existing) {
|
|
808
|
+
const res = await createRequirement(ctx, body);
|
|
809
|
+
if (res.status !== 201) return res;
|
|
810
|
+
const data = await res.json() as Record<string, unknown>;
|
|
811
|
+
return Response.json({ ...data, created: true }, { status: 201 });
|
|
812
|
+
}
|
|
813
|
+
const { external_ref: _ref, network_id: _net, client_id: _client, ...patch } = body;
|
|
814
|
+
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(patch, k))) return Response.json({ ok: true, requirement: toPublicFor(ctx, existing), created: false });
|
|
815
|
+
const res = patchRequirement(ctx, existing, patch);
|
|
816
|
+
if (res.status !== 200) return res;
|
|
817
|
+
const data = await res.json() as Record<string, unknown>;
|
|
818
|
+
return Response.json({ ...data, created: false });
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const itemMatch = url.pathname.match(/^\/api\/requirements\/([^/]+)\/checklist\/([^/]+)$/);
|
|
822
|
+
if (itemMatch) {
|
|
823
|
+
if (req.method !== "PATCH") return jsonError("not_found", 404);
|
|
824
|
+
let body: Record<string, unknown>;
|
|
825
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
826
|
+
if (typeof body.done !== "boolean") return jsonError("invalid_done", 400);
|
|
827
|
+
const row = scopedRow(ctx, decodeURIComponent(itemMatch[1]));
|
|
828
|
+
if (!row) return jsonError("requirement_not_found", 404);
|
|
829
|
+
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
830
|
+
// 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
|
|
831
|
+
const items = storedChecklist(row.checklist_json);
|
|
832
|
+
const itemId = decodeURIComponent(itemMatch[2]);
|
|
833
|
+
const item = items.find(entry => entry.id === itemId);
|
|
834
|
+
if (!item) return jsonError("checklist_item_not_found", 404);
|
|
835
|
+
item.done = body.done;
|
|
836
|
+
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]);
|
|
837
|
+
const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
838
|
+
return Response.json({ ok: true, requirement: toPublicFor(ctx, updated) });
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const match = url.pathname.match(/^\/api\/requirements\/([^/]+)$/);
|
|
842
|
+
if (!match) return jsonError("not_found", 404);
|
|
843
|
+
const id = decodeURIComponent(match[1]);
|
|
844
|
+
if (req.method === "GET") {
|
|
845
|
+
const row = scopedRow(ctx, id);
|
|
846
|
+
return row ? Response.json({ ok: true, requirement: toPublicFor(ctx, row) }) : jsonError("requirement_not_found", 404);
|
|
847
|
+
}
|
|
848
|
+
if (req.method === "DELETE") {
|
|
849
|
+
// 删除只给人(节点令牌在入口已被拒):Agent 用 archived 归档。
|
|
850
|
+
const row = scopedRow(ctx, id);
|
|
851
|
+
if (!row) return jsonError("requirement_not_found", 404);
|
|
852
|
+
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
853
|
+
// 子需求不跟着删:先解挂(变成顶层),再删父卡。
|
|
854
|
+
db.run("UPDATE requirements SET parent_id = NULL WHERE parent_id = ?1", [row.requirement_id]);
|
|
855
|
+
db.run("DELETE FROM requirements WHERE requirement_id = ?1", [row.requirement_id]);
|
|
856
|
+
return Response.json({ ok: true, deleted: row.requirement_id });
|
|
857
|
+
}
|
|
858
|
+
if (req.method !== "PATCH") return jsonError("not_found", 404);
|
|
859
|
+
let body: Record<string, unknown>;
|
|
860
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
861
|
+
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
|
|
862
|
+
const row = scopedRow(ctx, id);
|
|
863
|
+
if (!row) return jsonError("requirement_not_found", 404);
|
|
864
|
+
return patchRequirement(ctx, row, body);
|
|
865
|
+
}
|