@sleep2agi/commhub-server 0.9.0-preview.65 → 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 +8 -1
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.ts +17 -1
- package/src/requirements-http.test.ts +399 -10
- package/src/requirements-mcp.test.ts +132 -0
- package/src/requirements-migrate.ts +51 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +607 -95
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/rest-projections.ts +1 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +90 -0
- package/src/scheduled-tasks.ts +45 -10
- package/src/server.ts +1 -0
- package/src/tools.ts +183 -11
package/src/requirements.ts
CHANGED
|
@@ -2,8 +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 { ensureRequirementIndexes, ensureRequirementProjects, migrateRequirementAgentOwners } from "./requirements-migrate.js";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
// 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
|
|
8
|
+
// 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
|
|
9
|
+
migrateRequirementAgentOwners(db);
|
|
10
|
+
ensureRequirementProjects(db);
|
|
11
|
+
ensureRequirementIndexes(db);
|
|
12
|
+
|
|
13
|
+
type RequestAuth = { userId: string; networkId: string | null; username: string; tokenId?: string | null; tokenName?: string | null } | null;
|
|
7
14
|
|
|
8
15
|
export type RequirementsRequestContext = {
|
|
9
16
|
req: Request;
|
|
@@ -20,6 +27,20 @@ const DUE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
20
27
|
|
|
21
28
|
type Row = {
|
|
22
29
|
owner_json: string | null;
|
|
30
|
+
agent_owner_json: string | null;
|
|
31
|
+
description: string | null;
|
|
32
|
+
checklist_json: string | null;
|
|
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;
|
|
23
44
|
participants_json: string;
|
|
24
45
|
requirement_id: string;
|
|
25
46
|
network_id: string;
|
|
@@ -43,6 +64,26 @@ function dueOk(due: string): boolean {
|
|
|
43
64
|
return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
|
|
44
65
|
}
|
|
45
66
|
|
|
67
|
+
// 预计完成:两种形状都收。
|
|
68
|
+
// YYYY-MM-DD 全天(旧值原样保存、原样返回;客户端按查看者本地时区的那一天理解)
|
|
69
|
+
// YYYY-MM-DDTHH:MM[:SS][.fff](Z|±HH:MM) 精确到秒的时刻 → 统一存成 UTC「YYYY-MM-DDTHH:MM:SSZ」
|
|
70
|
+
// 不带时区的时刻拒收(无法知道是谁的本地时间)。返回 null = 不合法。
|
|
71
|
+
const DUE_DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d{1,9})?(Z|[+-](\d{2}):(\d{2}))$/;
|
|
72
|
+
export function normalizeDue(raw: string): string | null {
|
|
73
|
+
const due = raw.trim();
|
|
74
|
+
if (!due) return "";
|
|
75
|
+
if (dueOk(due)) return due;
|
|
76
|
+
const m = DUE_DATETIME.exec(due);
|
|
77
|
+
if (!m) return null;
|
|
78
|
+
if (!dueOk(`${m[1]}-${m[2]}-${m[3]}`)) return null;
|
|
79
|
+
const [hh, mm, ss] = [Number(m[4]), Number(m[5]), Number(m[6] ?? 0)];
|
|
80
|
+
if (hh > 23 || mm > 59 || ss > 59) return null;
|
|
81
|
+
if (m[7] !== "Z" && (Number(m[8]) > 14 || Number(m[9]) > 59)) return null;
|
|
82
|
+
const ms = Date.parse(due);
|
|
83
|
+
if (!Number.isFinite(ms)) return null;
|
|
84
|
+
return new Date(Math.floor(ms / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
85
|
+
}
|
|
86
|
+
|
|
46
87
|
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
47
88
|
const ISSUE_URL = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/issues\/(\d+)\/?$/;
|
|
48
89
|
|
|
@@ -96,9 +137,143 @@ function storedIssues(raw: string | null) {
|
|
|
96
137
|
}
|
|
97
138
|
}
|
|
98
139
|
|
|
140
|
+
// ── 描述 / 子任务 ──
|
|
141
|
+
// 描述是 markdown 原文,整段替换。子任务是有序数组 [{id,text,done}]:PATCH 整个替换(排序、增删都走这里),
|
|
142
|
+
// 单项勾选走 PATCH /api/requirements/{id}/checklist/{itemId} {done} —— 只改那一项,app 和 Agent 同时操作
|
|
143
|
+
// 不会互相盖掉对方的整张清单。done 是显式值(不是「取反」),重复请求结果一样。
|
|
144
|
+
export const DESCRIPTION_MAX = 20_000;
|
|
145
|
+
export const CHECKLIST_MAX_ITEMS = 100;
|
|
146
|
+
export const CHECKLIST_TEXT_MAX = 500;
|
|
147
|
+
const CHECKLIST_ID = /^[A-Za-z0-9_-]{1,40}$/;
|
|
148
|
+
type ChecklistItem = { id: string; text: string; done: boolean };
|
|
149
|
+
|
|
150
|
+
function normalizeDescription(raw: unknown): string | null {
|
|
151
|
+
if (typeof raw !== "string") return null;
|
|
152
|
+
const text = raw.replace(/\r\n?/g, "\n");
|
|
153
|
+
return text.length > DESCRIPTION_MAX ? null : text;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 输入的 id 保留(客户端要靠它对上勾选);缺 id 的由 Hub 生成。重复 id、空文字、超长 → null(整体拒绝)。 */
|
|
157
|
+
function normalizeChecklist(raw: unknown): ChecklistItem[] | null {
|
|
158
|
+
if (!Array.isArray(raw) || raw.length > CHECKLIST_MAX_ITEMS) return null;
|
|
159
|
+
const seen = new Set<string>();
|
|
160
|
+
const out: ChecklistItem[] = [];
|
|
161
|
+
for (const value of raw) {
|
|
162
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
163
|
+
const item = value as Record<string, unknown>;
|
|
164
|
+
const id = item.id === undefined ? `ck_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}` : item.id;
|
|
165
|
+
if (typeof id !== "string" || !CHECKLIST_ID.test(id) || seen.has(id)) return null;
|
|
166
|
+
const text = typeof item.text === "string" ? item.text.replace(/[\r\n]+/g, " ").trim() : "";
|
|
167
|
+
if (!text || text.length > CHECKLIST_TEXT_MAX) return null;
|
|
168
|
+
if (item.done !== undefined && typeof item.done !== "boolean") return null;
|
|
169
|
+
seen.add(id);
|
|
170
|
+
out.push({ id, text, done: item.done === true });
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function storedChecklist(raw: string | null): ChecklistItem[] {
|
|
176
|
+
if (!raw) return [];
|
|
177
|
+
try { return normalizeChecklist(JSON.parse(raw)) ?? []; } catch { return []; }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── 项目 ──
|
|
181
|
+
// 按网络隔离;名字 1–40 字,同一网络里未归档的项目不重名;颜色 #RRGGBB;sort 越小越靠前。
|
|
182
|
+
// 删除 = 删项目行 + 把引用它的卡片 project_id 置空(卡片本身不动);归档 = 保留引用,只是不能再被选。
|
|
183
|
+
type ProjectRow = { project_id: string; network_id: string; name: string; color: string; sort: number; archived: number; created_at: string };
|
|
184
|
+
const PROJECT_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
185
|
+
const PROJECT_PALETTE = ["#2563eb", "#16a34a", "#d97706", "#dc2626", "#7c3aed", "#0891b2", "#db2777", "#4b5563"];
|
|
186
|
+
const PROJECT_SELECT = "project_id, network_id, name, color, sort, archived, created_at";
|
|
187
|
+
const projectPublic = (row: ProjectRow) => ({ id: row.project_id, name: row.name, color: row.color, sort: row.sort, archived: !!row.archived, createdAt: row.created_at });
|
|
188
|
+
const projectName = (raw: unknown): string | null => {
|
|
189
|
+
if (typeof raw !== "string") return null;
|
|
190
|
+
const name = raw.replace(/[\r\n\t]+/g, " ").trim();
|
|
191
|
+
return name && name.length <= 40 ? name : null;
|
|
192
|
+
};
|
|
193
|
+
function projectNameTaken(networkId: string, name: string, except?: string): boolean {
|
|
194
|
+
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 ?? "");
|
|
195
|
+
}
|
|
196
|
+
/** 卡片上的 project_id:null 清空;字符串必须是同一网络、未归档的项目。 */
|
|
197
|
+
function projectRef(value: unknown, networkId: string): string | null {
|
|
198
|
+
if (value === null) return null;
|
|
199
|
+
if (typeof value !== "string" || !value) throw new Error("invalid_project");
|
|
200
|
+
const row = db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, value);
|
|
201
|
+
if (!row || row.network_id !== networkId) throw new Error("project_not_in_network");
|
|
202
|
+
if (row.archived) throw new Error("project_archived");
|
|
203
|
+
return row.project_id;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function handleProjects(ctx: RequirementsRequestContext): Promise<Response> {
|
|
207
|
+
const { req, url } = ctx;
|
|
208
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
209
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
210
|
+
const one = url.pathname.match(/^\/api\/requirements\/projects\/([^/]+)$/);
|
|
211
|
+
if (url.pathname === "/api/requirements/projects" && req.method === "GET") {
|
|
212
|
+
const rows = db.all<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE network_id = ?1 ORDER BY sort, created_at`, networkId);
|
|
213
|
+
return Response.json({ ok: true, projects: rows.map(projectPublic) });
|
|
214
|
+
}
|
|
215
|
+
if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
216
|
+
let body: Record<string, unknown> = {};
|
|
217
|
+
if (req.method === "POST" || req.method === "PATCH") {
|
|
218
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
219
|
+
}
|
|
220
|
+
if (url.pathname === "/api/requirements/projects" && req.method === "POST") {
|
|
221
|
+
const name = projectName(body.name);
|
|
222
|
+
if (!name) return jsonError("invalid_project_name", 400);
|
|
223
|
+
if (projectNameTaken(networkId, name)) return jsonError("project_name_taken", 409);
|
|
224
|
+
const count = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirement_projects WHERE network_id = ?1", networkId)?.n ?? 0;
|
|
225
|
+
if (count >= 200) return jsonError("too_many_projects", 400);
|
|
226
|
+
const color = body.color === undefined ? PROJECT_PALETTE[count % PROJECT_PALETTE.length] : body.color;
|
|
227
|
+
if (typeof color !== "string" || !PROJECT_COLOR.test(color)) return jsonError("invalid_project_color", 400);
|
|
228
|
+
const sort = body.sort === undefined ? count : body.sort;
|
|
229
|
+
if (!Number.isInteger(sort)) return jsonError("invalid_project_sort", 400);
|
|
230
|
+
const id = `proj_${crypto.randomUUID()}`;
|
|
231
|
+
db.run(`INSERT INTO requirement_projects (${PROJECT_SELECT}) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6)`, [id, networkId, name, color, sort, new Date().toISOString()]);
|
|
232
|
+
return Response.json({ ok: true, project: projectPublic(db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, id)!) }, { status: 201 });
|
|
233
|
+
}
|
|
234
|
+
if (!one) return jsonError("not_found", 404);
|
|
235
|
+
const current = db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, decodeURIComponent(one[1]));
|
|
236
|
+
if (!current || current.network_id !== networkId) return jsonError("project_not_found", 404);
|
|
237
|
+
if (req.method === "DELETE") {
|
|
238
|
+
// 先把卡片上的引用置空,再删项目:卡片一张不少。
|
|
239
|
+
db.run("UPDATE requirements SET project_id = NULL WHERE project_id = ?1", [current.project_id]);
|
|
240
|
+
db.run("DELETE FROM requirement_projects WHERE project_id = ?1", [current.project_id]);
|
|
241
|
+
return Response.json({ ok: true });
|
|
242
|
+
}
|
|
243
|
+
if (req.method !== "PATCH") return jsonError("not_found", 404);
|
|
244
|
+
let { name, color, sort, archived } = current;
|
|
245
|
+
if ("name" in body) {
|
|
246
|
+
const next = projectName(body.name);
|
|
247
|
+
if (!next) return jsonError("invalid_project_name", 400);
|
|
248
|
+
name = next;
|
|
249
|
+
}
|
|
250
|
+
if ("color" in body) {
|
|
251
|
+
if (typeof body.color !== "string" || !PROJECT_COLOR.test(body.color)) return jsonError("invalid_project_color", 400);
|
|
252
|
+
color = body.color;
|
|
253
|
+
}
|
|
254
|
+
if ("sort" in body) {
|
|
255
|
+
if (!Number.isInteger(body.sort)) return jsonError("invalid_project_sort", 400);
|
|
256
|
+
sort = body.sort as number;
|
|
257
|
+
}
|
|
258
|
+
if ("archived" in body) {
|
|
259
|
+
if (typeof body.archived !== "boolean") return jsonError("invalid_project_archived", 400);
|
|
260
|
+
archived = body.archived ? 1 : 0;
|
|
261
|
+
}
|
|
262
|
+
if (!archived && projectNameTaken(networkId, name, current.project_id)) return jsonError("project_name_taken", 409);
|
|
263
|
+
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]);
|
|
264
|
+
return Response.json({ ok: true, project: projectPublic(db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, current.project_id)!) });
|
|
265
|
+
}
|
|
266
|
+
|
|
99
267
|
function toPublic(row: Row) {
|
|
100
268
|
return {
|
|
101
269
|
owner: row.owner_json ? JSON.parse(row.owner_json) : null,
|
|
270
|
+
// 负责 Agent(执行者)。字段总在:客户端靠它判断这个 Hub 分不分「负责人 / 负责 Agent」。
|
|
271
|
+
agent_owner: row.agent_owner_json ? JSON.parse(row.agent_owner_json) : null,
|
|
272
|
+
// 描述(markdown)与子任务。字段总在:客户端靠它判断这个 Hub 支不支持。
|
|
273
|
+
description: row.description || "",
|
|
274
|
+
checklist: storedChecklist(row.checklist_json),
|
|
275
|
+
// 项目(可空)。字段总在:客户端靠它判断这个 Hub 有没有项目。项目被删 → 置空;被归档 → 引用保留。
|
|
276
|
+
project_id: row.project_id || null,
|
|
102
277
|
participants: JSON.parse(row.participants_json || '[]'),
|
|
103
278
|
id: row.requirement_id,
|
|
104
279
|
name: row.title,
|
|
@@ -107,7 +282,18 @@ function toPublic(row: Row) {
|
|
|
107
282
|
due: row.due_on || "",
|
|
108
283
|
column: row.column_name,
|
|
109
284
|
createdAt: row.created_at,
|
|
285
|
+
updatedAt: row.updated_at || row.created_at,
|
|
110
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) },
|
|
111
297
|
};
|
|
112
298
|
}
|
|
113
299
|
|
|
@@ -124,10 +310,14 @@ function writeNetwork(body: Record<string, unknown>, ctx: RequirementsRequestCon
|
|
|
124
310
|
}
|
|
125
311
|
|
|
126
312
|
function canWrite(ctx: RequirementsRequestContext, networkId: string | null): boolean {
|
|
127
|
-
|
|
313
|
+
// 节点令牌只能写它绑定的那个网络(scope 已强制;这里再核一次,防止别的入口绕过 scope)。
|
|
314
|
+
if (ctx.isNodeToken && (!ctx.auth?.networkId || ctx.auth.networkId !== networkId)) return false;
|
|
315
|
+
return canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
|
|
128
316
|
}
|
|
129
317
|
|
|
130
|
-
const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, created_at, owner_json, participants_json"
|
|
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";
|
|
131
321
|
|
|
132
322
|
type PersonRef = { kind: 'user' | 'node'; id: string };
|
|
133
323
|
function personRef(value: unknown, networkId: string): PersonRef {
|
|
@@ -141,21 +331,366 @@ function personRef(value: unknown, networkId: string): PersonRef {
|
|
|
141
331
|
return { kind: row.kind, id: row.id };
|
|
142
332
|
}
|
|
143
333
|
|
|
334
|
+
// 负责人只能是人类,负责 Agent 只能是节点;参与人两种都行。库里旧的节点负责人照样读出来(启动迁移会挪走),
|
|
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。
|
|
144
339
|
function assignments(body: Record<string, unknown>, networkId: string, current?: Row) {
|
|
145
|
-
|
|
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;
|
|
343
|
+
if ('owner' in body) {
|
|
344
|
+
owner = body.owner === null ? null : personRef(body.owner, networkId);
|
|
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
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if ('agent_owner' in body) {
|
|
353
|
+
agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId);
|
|
354
|
+
if (agentOwner && agentOwner.kind !== 'node') throw new Error('agent_owner_must_be_agent');
|
|
355
|
+
}
|
|
146
356
|
let participants = current ? JSON.parse(current.participants_json || '[]') : [];
|
|
147
357
|
if ('participants' in body) {
|
|
148
358
|
if (!Array.isArray(body.participants) || body.participants.length > 100) throw new Error('invalid_participants');
|
|
149
359
|
const refs = body.participants.map(value => personRef(value, networkId));
|
|
150
360
|
participants = [...new Map(refs.map(ref => [`${ref.kind}:${ref.id}`, ref])).values()];
|
|
151
361
|
}
|
|
152
|
-
return {
|
|
362
|
+
return {
|
|
363
|
+
coerced,
|
|
364
|
+
ownerJson: owner === null ? null : JSON.stringify(owner),
|
|
365
|
+
agentOwnerJson: agentOwner === null ? null : JSON.stringify(agentOwner),
|
|
366
|
+
participantsJson: JSON.stringify(participants),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
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"]);
|
|
375
|
+
function operationOf(req: Request, url: URL): string {
|
|
376
|
+
if (/^\/api\/requirements\/projects(\/|$)/.test(url.pathname)) return req.method === "GET" ? "projects_read" : "projects_write";
|
|
377
|
+
if (req.method === "GET") return "read";
|
|
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";
|
|
381
|
+
if (req.method === "PATCH") return "patch";
|
|
382
|
+
return "create";
|
|
383
|
+
}
|
|
384
|
+
|
|
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;
|
|
153
684
|
}
|
|
154
685
|
|
|
155
686
|
export async function handleRequirementsRequest(ctx: RequirementsRequestContext): Promise<Response | null> {
|
|
156
687
|
const { req, url } = ctx;
|
|
157
688
|
if (url.pathname !== "/api/requirements" && !url.pathname.startsWith("/api/requirements/")) return null;
|
|
158
|
-
|
|
689
|
+
// 节点令牌只能做 NODE_TOKEN_OPERATIONS 里的事(读 / 建 / 改 / 勾子任务 / upsert / 读项目),
|
|
690
|
+
// 而且只在它绑定的网络里 —— 范围由 scope 强制,写入再由 canWrite 核一次。
|
|
691
|
+
if (ctx.isNodeToken && !NODE_TOKEN_OPERATIONS.has(operationOf(req, url))) return jsonError("user_token_required", 403);
|
|
692
|
+
|
|
693
|
+
if (url.pathname === "/api/requirements/projects" || url.pathname.startsWith("/api/requirements/projects/")) return handleProjects(ctx);
|
|
159
694
|
|
|
160
695
|
if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
|
|
161
696
|
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
@@ -173,109 +708,86 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
173
708
|
const params: unknown[] = [];
|
|
174
709
|
let sql = `SELECT ${SELECT} FROM requirements WHERE 1=1`;
|
|
175
710
|
sql = addNetworkScope(sql, params, ctx.scope);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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);
|
|
714
|
+
// capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
|
|
715
|
+
return Response.json({ ok: true, requirements: rows, capabilities: REQUIREMENT_CAPABILITIES });
|
|
179
716
|
}
|
|
180
717
|
|
|
181
718
|
if (url.pathname === "/api/requirements" && req.method === "POST") {
|
|
182
719
|
let body: Record<string, unknown>;
|
|
183
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);
|
|
184
731
|
const networkId = writeNetwork(body, ctx);
|
|
185
732
|
if (!networkId) return jsonError("network_id_required", 400);
|
|
186
733
|
if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
187
|
-
const
|
|
188
|
-
if (!
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const assignee = typeof body.assignee === "string" ? body.assignee.trim().slice(0, 80) : "";
|
|
194
|
-
const clientId = typeof body.client_id === "string" ? body.client_id.trim() : "";
|
|
195
|
-
if (clientId && !/^[A-Za-z0-9._-]{1,80}$/.test(clientId)) return jsonError("invalid_client_id", 400);
|
|
196
|
-
const column = typeof body.column === "string" && COLUMNS.has(body.column) ? body.column : "pool";
|
|
197
|
-
const issues = body.issues === undefined ? [] : normalizeIssues(body.issues);
|
|
198
|
-
if (issues === null) return jsonError("invalid_issues", 400);
|
|
199
|
-
if (clientId) {
|
|
200
|
-
const existing = db.get<Row>(
|
|
201
|
-
`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`,
|
|
202
|
-
networkId, clientId,
|
|
203
|
-
);
|
|
204
|
-
if (existing) return Response.json({ ok: true, requirement: toPublic(existing) });
|
|
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 });
|
|
205
740
|
}
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
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 });
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const itemMatch = url.pathname.match(/^\/api\/requirements\/([^/]+)\/checklist\/([^/]+)$/);
|
|
750
|
+
if (itemMatch) {
|
|
751
|
+
if (req.method !== "PATCH") return jsonError("not_found", 404);
|
|
752
|
+
let body: Record<string, unknown>;
|
|
753
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
754
|
+
if (typeof body.done !== "boolean") return jsonError("invalid_done", 400);
|
|
755
|
+
const row = scopedRow(ctx, decodeURIComponent(itemMatch[1]));
|
|
756
|
+
if (!row) return jsonError("requirement_not_found", 404);
|
|
757
|
+
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
758
|
+
// 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
|
|
759
|
+
const items = storedChecklist(row.checklist_json);
|
|
760
|
+
const itemId = decodeURIComponent(itemMatch[2]);
|
|
761
|
+
const item = items.find(entry => entry.id === itemId);
|
|
762
|
+
if (!item) return jsonError("checklist_item_not_found", 404);
|
|
763
|
+
item.done = body.done;
|
|
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]);
|
|
765
|
+
const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
766
|
+
return Response.json({ ok: true, requirement: toPublic(updated) });
|
|
228
767
|
}
|
|
229
768
|
|
|
230
769
|
const match = url.pathname.match(/^\/api\/requirements\/([^/]+)$/);
|
|
231
|
-
if (!match
|
|
770
|
+
if (!match) return jsonError("not_found", 404);
|
|
232
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);
|
|
233
787
|
let body: Record<string, unknown>;
|
|
234
788
|
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
const hasName = 'name' in body;
|
|
238
|
-
const hasPriority = 'priority' in body;
|
|
239
|
-
const hasDue = 'due' in body;
|
|
240
|
-
const hasAssignee = 'assignee' in body;
|
|
241
|
-
if (!hasColumn && !hasIssues && !hasName && !hasPriority && !hasDue && !hasAssignee && !('owner' in body) && !('participants' in body)) return jsonError('empty_patch', 400);
|
|
242
|
-
if (hasColumn && !COLUMNS.has(String(body.column))) return jsonError("invalid_column", 400);
|
|
243
|
-
const issues = hasIssues ? normalizeIssues(body.issues) : null;
|
|
244
|
-
if (hasIssues && issues === null) return jsonError("invalid_issues", 400);
|
|
245
|
-
const params: unknown[] = [id];
|
|
246
|
-
let sql = `SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`;
|
|
247
|
-
sql = addNetworkScope(sql, params, ctx.scope);
|
|
248
|
-
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);
|
|
249
791
|
if (!row) return jsonError("requirement_not_found", 404);
|
|
250
|
-
|
|
251
|
-
let name = row.title;
|
|
252
|
-
if (hasName) {
|
|
253
|
-
name = typeof body.name === "string" ? body.name.trim() : "";
|
|
254
|
-
if (!name || name.length > 80) return jsonError("invalid_name", 400);
|
|
255
|
-
}
|
|
256
|
-
let priority = row.priority;
|
|
257
|
-
if (hasPriority) {
|
|
258
|
-
if (typeof body.priority !== "string" || !PRIORITIES.has(body.priority)) return jsonError("invalid_priority", 400);
|
|
259
|
-
priority = body.priority;
|
|
260
|
-
}
|
|
261
|
-
let due = row.due_on || "";
|
|
262
|
-
if (hasDue) {
|
|
263
|
-
if (typeof body.due !== "string") return jsonError("invalid_due", 400);
|
|
264
|
-
due = body.due.trim();
|
|
265
|
-
if (due && !dueOk(due)) return jsonError("invalid_due", 400);
|
|
266
|
-
}
|
|
267
|
-
let assignee = row.assignee || "";
|
|
268
|
-
if (hasAssignee) {
|
|
269
|
-
if (typeof body.assignee !== "string") return jsonError("invalid_assignee", 400);
|
|
270
|
-
assignee = body.assignee.trim().slice(0, 80);
|
|
271
|
-
}
|
|
272
|
-
let people;
|
|
273
|
-
try { people = assignments(body, row.network_id, row); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
274
|
-
const updatedAt = new Date().toISOString();
|
|
275
|
-
db.run(
|
|
276
|
-
"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 WHERE requirement_id = ?3",
|
|
277
|
-
[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],
|
|
278
|
-
);
|
|
279
|
-
const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
280
|
-
return Response.json({ ok: true, requirement: toPublic(updated) });
|
|
792
|
+
return patchRequirement(ctx, row, body);
|
|
281
793
|
}
|