@sleep2agi/commhub-server 0.9.0-preview.8 → 0.9.0-preview.80
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 +130 -10
- package/bin/commhub.ts +92 -11
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +5 -5
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +478 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -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 +450 -31
- 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/commhub-bin-args.test.ts +85 -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 +46 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +805 -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/dm-file-scope-http.test.ts +178 -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 +94 -0
- package/src/http-gzip.ts +110 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -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-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -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/pg-worker.ts +87 -0
- package/src/push.ts +284 -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-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +143 -0
- package/src/requirement-notify.ts +134 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-list-slim-http.test.ts +315 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +138 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1439 -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 +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- 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-reply-unread-http.test.ts +212 -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 +638 -0
- package/src/scheduled-tasks.ts +597 -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 +4522 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +312 -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 +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +307 -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 +3534 -343
- 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 +138 -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,1439 @@
|
|
|
1
|
+
// 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
|
|
2
|
+
// 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
|
|
5
|
+
import { db, logAudit } from "./db.js";
|
|
6
|
+
import { markGzipReusable } from "./http-gzip.js";
|
|
7
|
+
import { applyTagOp, normalizeTags, parseTagOp, storedTags, type TagOp } from "./requirement-tags.js";
|
|
8
|
+
import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-stats.js";
|
|
9
|
+
import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
10
|
+
import { isAgentRestricted, restrictedNetworkIds, visibleAgents } from "./agent-access.js";
|
|
11
|
+
import {
|
|
12
|
+
addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, canParticipantEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject,
|
|
13
|
+
isTaskScoped, PARTICIPANT_EDIT_FIELDS, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
|
|
14
|
+
} from "./task-access.js";
|
|
15
|
+
import { notifyParticipantChange } from "./requirement-notify.js";
|
|
16
|
+
import { diffRequirement, ensureRequirementEvents, eventPublic, recordRequirementEvents, type EventRow } from "./requirement-events.js";
|
|
17
|
+
import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
|
|
18
|
+
|
|
19
|
+
// 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
|
|
20
|
+
// 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
|
|
21
|
+
migrateRequirementPriorityCheck(db);
|
|
22
|
+
migrateRequirementAgentOwners(db);
|
|
23
|
+
ensureRequirementProjects(db);
|
|
24
|
+
ensureRequirementIndexes(db);
|
|
25
|
+
ensureRequirementSeq(db);
|
|
26
|
+
ensureRequirementCompletedAt(db);
|
|
27
|
+
ensureNetworkTags(db);
|
|
28
|
+
ensureRequirementTombstones(db);
|
|
29
|
+
ensureRequirementEvents(db);
|
|
30
|
+
|
|
31
|
+
type RequestAuth = { userId: string; networkId: string | null; username: string; tokenId?: string | null; tokenName?: string | null } | null;
|
|
32
|
+
|
|
33
|
+
export type RequirementsRequestContext = {
|
|
34
|
+
req: Request;
|
|
35
|
+
url: URL;
|
|
36
|
+
auth: RequestAuth;
|
|
37
|
+
isAdmin: boolean;
|
|
38
|
+
isNodeToken: boolean;
|
|
39
|
+
scope: RestNetworkScope;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const COLUMNS = new Set(["pool", "doing", "done"]);
|
|
43
|
+
// 界面上 P0–P3:high=P0 最高、normal=P1 普通、low=P2 低、lowest=P3 极低。存的值不变,旧客户端照常读写前三个。
|
|
44
|
+
const PRIORITIES = new Set(["high", "normal", "low", "lowest"]);
|
|
45
|
+
const DUE = /^\d{4}-\d{2}-\d{2}$/;
|
|
46
|
+
|
|
47
|
+
type Row = {
|
|
48
|
+
owner_json: string | null;
|
|
49
|
+
agent_owner_json: string | null;
|
|
50
|
+
description: string | null;
|
|
51
|
+
checklist_json: string | null;
|
|
52
|
+
project_id: string | null;
|
|
53
|
+
external_ref: string | null;
|
|
54
|
+
external_url: string | null;
|
|
55
|
+
archived: number | null;
|
|
56
|
+
created_by: string | null;
|
|
57
|
+
created_by_json: string | null;
|
|
58
|
+
updated_by_json: string | null;
|
|
59
|
+
updated_at: string | null;
|
|
60
|
+
parent_id: string | null;
|
|
61
|
+
children_total: number | null;
|
|
62
|
+
children_done: number | null;
|
|
63
|
+
participants_json: string;
|
|
64
|
+
requirement_id: string;
|
|
65
|
+
network_id: string;
|
|
66
|
+
title: string;
|
|
67
|
+
column_name: string;
|
|
68
|
+
priority: string;
|
|
69
|
+
due_on: string | null;
|
|
70
|
+
start_on: string | null;
|
|
71
|
+
assignee: string | null;
|
|
72
|
+
issues_json: string | null;
|
|
73
|
+
tags_json: string | null;
|
|
74
|
+
created_at: string;
|
|
75
|
+
seq: number | null;
|
|
76
|
+
completed_at: string | null;
|
|
77
|
+
completed_by_json: string | null;
|
|
78
|
+
completed_at_approx: number | null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function jsonError(error: string, status: number): Response {
|
|
82
|
+
return Response.json({ ok: false, error }, { status });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── 任务的人员权限(RFC-038 §9,判定在 task-access.ts) ──
|
|
86
|
+
// 节点令牌(Agent)、Hub 管理员、没有身份的旧全局令牌:不受任务权限约束(null)。
|
|
87
|
+
function taskCaller(ctx: RequirementsRequestContext): TaskCaller {
|
|
88
|
+
if (!ctx.auth || ctx.isNodeToken || ctx.isAdmin) return null;
|
|
89
|
+
return { userId: ctx.auth.userId };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 看得见但没权限写:403,并按 (用户, 卡) 每小时最多记一条审计。
|
|
93
|
+
* field:参与人改了参与人不能改的字段 —— 错误码不变(旧 App 照旧认 task_read_only),多带 field + 一句中文 message。
|
|
94
|
+
*/
|
|
95
|
+
function taskDenied(ctx: RequirementsRequestContext, row: Row, error: "task_read_only" | "task_delete_denied", field?: string): Response {
|
|
96
|
+
if (ctx.auth && shouldAuditDenied(ctx.auth.userId, row.requirement_id)) {
|
|
97
|
+
logAudit(ctx.auth.userId, ctx.auth.username || null, "task_access_denied", "requirement", row.requirement_id, JSON.stringify(field ? { error, field } : { error }), undefined, row.network_id);
|
|
98
|
+
}
|
|
99
|
+
if (!field) return jsonError(error, 403);
|
|
100
|
+
return Response.json({ ok: false, error, field, message: `参与人只能修改状态和检查项,不能修改「${FIELD_LABEL[field] ?? field}」` }, { status: 403 });
|
|
101
|
+
}
|
|
102
|
+
const FIELD_LABEL: Record<string, string> = {
|
|
103
|
+
name: "标题", description: "描述", owner: "负责人", agent_owner: "负责 Agent", participants: "参与人", project_id: "项目",
|
|
104
|
+
priority: "优先级", due: "预计完成", start: "开始时间", tags: "标签", issues: "关联 issue", assignee: "执行人",
|
|
105
|
+
external_ref: "外部引用", external_url: "外部链接", archived: "归档", parent_id: "父任务",
|
|
106
|
+
};
|
|
107
|
+
/** 参与人改完(事务提交后)发通知;操作者不是参与人 / 状态和检查项没变 → 不发。只对人(用户令牌)。 */
|
|
108
|
+
function notifyIfParticipant(ctx: RequirementsRequestContext, before: Row, after: Row): void {
|
|
109
|
+
if (!ctx.auth || ctx.isNodeToken) return;
|
|
110
|
+
try {
|
|
111
|
+
notifyParticipantChange({ before, after, actorUserId: ctx.auth.userId });
|
|
112
|
+
} catch (e) {
|
|
113
|
+
// 通知失败不影响这次改动(已经提交)。
|
|
114
|
+
console.error(`[requirements] participant notice failed: ${(e as Error).message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** 父卡对调用者不可见 = 与不存在同一个错误(不给 scoped 成员留卡片存在性探测)。 */
|
|
118
|
+
function parentHidden(ctx: RequirementsRequestContext, parentId: string): boolean {
|
|
119
|
+
const caller = taskCaller(ctx);
|
|
120
|
+
if (!caller) return false;
|
|
121
|
+
const parent = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, parentId);
|
|
122
|
+
return !!parent && !canSeeTask(caller, parent);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function dueOk(due: string): boolean {
|
|
126
|
+
if (!DUE.test(due)) return false;
|
|
127
|
+
const [y, m, d] = due.split("-").map(Number);
|
|
128
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
129
|
+
return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 预计完成:两种形状都收。
|
|
133
|
+
// YYYY-MM-DD 全天(旧值原样保存、原样返回;客户端按查看者本地时区的那一天理解)
|
|
134
|
+
// YYYY-MM-DDTHH:MM[:SS][.fff](Z|±HH:MM) 精确到秒的时刻 → 统一存成 UTC「YYYY-MM-DDTHH:MM:SSZ」
|
|
135
|
+
// 不带时区的时刻拒收(无法知道是谁的本地时间)。返回 null = 不合法。
|
|
136
|
+
const DUE_DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d{1,9})?(Z|[+-](\d{2}):(\d{2}))$/;
|
|
137
|
+
export function normalizeDue(raw: string): string | null {
|
|
138
|
+
const due = raw.trim();
|
|
139
|
+
if (!due) return "";
|
|
140
|
+
if (dueOk(due)) return due;
|
|
141
|
+
const m = DUE_DATETIME.exec(due);
|
|
142
|
+
if (!m) return null;
|
|
143
|
+
if (!dueOk(`${m[1]}-${m[2]}-${m[3]}`)) return null;
|
|
144
|
+
const [hh, mm, ss] = [Number(m[4]), Number(m[5]), Number(m[6] ?? 0)];
|
|
145
|
+
if (hh > 23 || mm > 59 || ss > 59) return null;
|
|
146
|
+
if (m[7] !== "Z" && (Number(m[8]) > 14 || Number(m[9]) > 59)) return null;
|
|
147
|
+
const ms = Date.parse(due);
|
|
148
|
+
if (!Number.isFinite(ms)) return null;
|
|
149
|
+
return new Date(Math.floor(ms / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
153
|
+
const ISSUE_URL = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/issues\/(\d+)\/?$/;
|
|
154
|
+
|
|
155
|
+
function clipTitle(value: unknown): string {
|
|
156
|
+
return typeof value === "string" ? value.replace(/[\r\n\t]/g, " ").trim().slice(0, 120) : "";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function issueFromParts(repo: string, number: number, title: string) {
|
|
160
|
+
if (!REPO.test(repo) || !Number.isInteger(number) || number < 1 || number > 10_000_000) return null;
|
|
161
|
+
return { repo, number, title: clipTitle(title) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function oneIssue(item: unknown) {
|
|
165
|
+
if (typeof item === "string") {
|
|
166
|
+
const m = ISSUE_URL.exec(item.trim());
|
|
167
|
+
return m ? issueFromParts(`${m[1]}/${m[2]}`, Number(m[3]), "") : null;
|
|
168
|
+
}
|
|
169
|
+
if (!item || typeof item !== "object") return null;
|
|
170
|
+
const row = item as Record<string, unknown>;
|
|
171
|
+
if (typeof row.url === "string") {
|
|
172
|
+
const m = ISSUE_URL.exec(row.url.trim());
|
|
173
|
+
if (m) return issueFromParts(`${m[1]}/${m[2]}`, Number(m[3]), clipTitle(row.title));
|
|
174
|
+
}
|
|
175
|
+
const repo = typeof row.repo === "string" ? row.repo.trim() : "";
|
|
176
|
+
const number = typeof row.number === "number" ? row.number : Number(row.number);
|
|
177
|
+
return issueFromParts(repo, number, clipTitle(row.title));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function normalizeIssues(raw: unknown) {
|
|
181
|
+
if (!Array.isArray(raw) || raw.length > 8) return null;
|
|
182
|
+
const out: { repo: string; number: number; title: string }[] = [];
|
|
183
|
+
const seen = new Set<string>();
|
|
184
|
+
for (const item of raw) {
|
|
185
|
+
const issue = oneIssue(item);
|
|
186
|
+
if (!issue) return null;
|
|
187
|
+
const key = `${issue.repo}#${issue.number}`;
|
|
188
|
+
if (seen.has(key)) continue;
|
|
189
|
+
seen.add(key);
|
|
190
|
+
out.push(issue);
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function storedIssues(raw: string | null) {
|
|
196
|
+
if (!raw) return [];
|
|
197
|
+
try {
|
|
198
|
+
const parsed = normalizeIssues(JSON.parse(raw));
|
|
199
|
+
return parsed ?? [];
|
|
200
|
+
} catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── 描述 / 子任务 ──
|
|
206
|
+
// 描述是 markdown 原文,整段替换。子任务是有序数组 [{id,text,done}]:PATCH 整个替换(排序、增删都走这里),
|
|
207
|
+
// 单项勾选走 PATCH /api/requirements/{id}/checklist/{itemId} {done} —— 只改那一项,app 和 Agent 同时操作
|
|
208
|
+
// 不会互相盖掉对方的整张清单。done 是显式值(不是「取反」),重复请求结果一样。
|
|
209
|
+
export const DESCRIPTION_MAX = 20_000;
|
|
210
|
+
export const CHECKLIST_MAX_ITEMS = 100;
|
|
211
|
+
export const CHECKLIST_TEXT_MAX = 500;
|
|
212
|
+
const CHECKLIST_ID = /^[A-Za-z0-9_-]{1,40}$/;
|
|
213
|
+
type ChecklistItem = { id: string; text: string; done: boolean };
|
|
214
|
+
|
|
215
|
+
function normalizeDescription(raw: unknown): string | null {
|
|
216
|
+
if (typeof raw !== "string") return null;
|
|
217
|
+
const text = raw.replace(/\r\n?/g, "\n");
|
|
218
|
+
return text.length > DESCRIPTION_MAX ? null : text;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 输入的 id 保留(客户端要靠它对上勾选);缺 id 的由 Hub 生成。重复 id、空文字、超长 → null(整体拒绝)。 */
|
|
222
|
+
function normalizeChecklist(raw: unknown): ChecklistItem[] | null {
|
|
223
|
+
if (!Array.isArray(raw) || raw.length > CHECKLIST_MAX_ITEMS) return null;
|
|
224
|
+
const seen = new Set<string>();
|
|
225
|
+
const out: ChecklistItem[] = [];
|
|
226
|
+
for (const value of raw) {
|
|
227
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
228
|
+
const item = value as Record<string, unknown>;
|
|
229
|
+
const id = item.id === undefined ? `ck_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}` : item.id;
|
|
230
|
+
if (typeof id !== "string" || !CHECKLIST_ID.test(id) || seen.has(id)) return null;
|
|
231
|
+
const text = typeof item.text === "string" ? item.text.replace(/[\r\n]+/g, " ").trim() : "";
|
|
232
|
+
if (!text || text.length > CHECKLIST_TEXT_MAX) return null;
|
|
233
|
+
if (item.done !== undefined && typeof item.done !== "boolean") return null;
|
|
234
|
+
seen.add(id);
|
|
235
|
+
out.push({ id, text, done: item.done === true });
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function storedChecklist(raw: string | null): ChecklistItem[] {
|
|
241
|
+
if (!raw) return [];
|
|
242
|
+
try { return normalizeChecklist(JSON.parse(raw)) ?? []; } catch { return []; }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── 项目 ──
|
|
246
|
+
// 按网络隔离;名字 1–40 字,同一网络里未归档的项目不重名;颜色 #RRGGBB;sort 越小越靠前。
|
|
247
|
+
// 删除 = 删项目行 + 把引用它的卡片 project_id 置空(卡片本身不动);归档 = 保留引用,只是不能再被选。
|
|
248
|
+
type ProjectRow = { project_id: string; network_id: string; name: string; color: string; sort: number; archived: number; created_at: string };
|
|
249
|
+
const PROJECT_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
250
|
+
const PROJECT_PALETTE = ["#2563eb", "#16a34a", "#d97706", "#dc2626", "#7c3aed", "#0891b2", "#db2777", "#4b5563"];
|
|
251
|
+
const PROJECT_SELECT = "project_id, network_id, name, color, sort, archived, created_at";
|
|
252
|
+
const projectPublic = (row: ProjectRow) => ({ id: row.project_id, name: row.name, color: row.color, sort: row.sort, archived: !!row.archived, createdAt: row.created_at });
|
|
253
|
+
const projectName = (raw: unknown): string | null => {
|
|
254
|
+
if (typeof raw !== "string") return null;
|
|
255
|
+
const name = raw.replace(/[\r\n\t]+/g, " ").trim();
|
|
256
|
+
return name && name.length <= 40 ? name : null;
|
|
257
|
+
};
|
|
258
|
+
function projectNameTaken(networkId: string, name: string, except?: string): boolean {
|
|
259
|
+
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 ?? "");
|
|
260
|
+
}
|
|
261
|
+
/** 卡片上的 project_id:null 清空;字符串必须是同一网络、未归档的项目。 */
|
|
262
|
+
function projectRef(value: unknown, networkId: string): string | null {
|
|
263
|
+
if (value === null) return null;
|
|
264
|
+
if (typeof value !== "string" || !value) throw new Error("invalid_project");
|
|
265
|
+
const row = db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, value);
|
|
266
|
+
if (!row || row.network_id !== networkId) throw new Error("project_not_in_network");
|
|
267
|
+
if (row.archived) throw new Error("project_archived");
|
|
268
|
+
return row.project_id;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function handleProjects(ctx: RequirementsRequestContext): Promise<Response> {
|
|
272
|
+
const { req, url } = ctx;
|
|
273
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
274
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
275
|
+
const one = url.pathname.match(/^\/api\/requirements\/projects\/([^/]+)$/);
|
|
276
|
+
if (url.pathname === "/api/requirements/projects" && req.method === "GET") {
|
|
277
|
+
// scoped 成员只列授权给自己的项目(RFC-038 §9.3)。
|
|
278
|
+
const projectParams: unknown[] = [networkId];
|
|
279
|
+
const projectSql = addProjectVisibilityScope(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE network_id = ?1`, projectParams, taskCaller(ctx), networkId);
|
|
280
|
+
const rows = db.all<ProjectRow>(`${projectSql} ORDER BY sort, created_at`, ...projectParams);
|
|
281
|
+
return Response.json({ ok: true, projects: rows.map(projectPublic) });
|
|
282
|
+
}
|
|
283
|
+
if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
284
|
+
// 项目管理(建 / 改名 / 归档 / 删)只给不受任务范围限制的人。
|
|
285
|
+
if (taskCaller(ctx) && isTaskScoped(ctx.auth!.userId, networkId)) return jsonError("permission_denied", 403);
|
|
286
|
+
let body: Record<string, unknown> = {};
|
|
287
|
+
if (req.method === "POST" || req.method === "PATCH") {
|
|
288
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
289
|
+
}
|
|
290
|
+
if (url.pathname === "/api/requirements/projects" && req.method === "POST") {
|
|
291
|
+
const name = projectName(body.name);
|
|
292
|
+
if (!name) return jsonError("invalid_project_name", 400);
|
|
293
|
+
if (projectNameTaken(networkId, name)) return jsonError("project_name_taken", 409);
|
|
294
|
+
const count = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirement_projects WHERE network_id = ?1", networkId)?.n ?? 0;
|
|
295
|
+
if (count >= 200) return jsonError("too_many_projects", 400);
|
|
296
|
+
const color = body.color === undefined ? PROJECT_PALETTE[count % PROJECT_PALETTE.length] : body.color;
|
|
297
|
+
if (typeof color !== "string" || !PROJECT_COLOR.test(color)) return jsonError("invalid_project_color", 400);
|
|
298
|
+
const sort = body.sort === undefined ? count : body.sort;
|
|
299
|
+
if (!Number.isInteger(sort)) return jsonError("invalid_project_sort", 400);
|
|
300
|
+
const id = `proj_${crypto.randomUUID()}`;
|
|
301
|
+
db.run(`INSERT INTO requirement_projects (${PROJECT_SELECT}) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6)`, [id, networkId, name, color, sort, new Date().toISOString()]);
|
|
302
|
+
return Response.json({ ok: true, project: projectPublic(db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, id)!) }, { status: 201 });
|
|
303
|
+
}
|
|
304
|
+
if (!one) return jsonError("not_found", 404);
|
|
305
|
+
const current = db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, decodeURIComponent(one[1]));
|
|
306
|
+
if (!current || current.network_id !== networkId) return jsonError("project_not_found", 404);
|
|
307
|
+
if (req.method === "DELETE") {
|
|
308
|
+
// 先把卡片上的引用置空,再删项目:卡片一张不少。
|
|
309
|
+
// updated_at 跟着动:按 updated_since 增量同步的客户端要看得见「项目被清空」这一改动。
|
|
310
|
+
// 每张被清空项目的卡记一条「项目 X → 无」(与清空同一个事务)。
|
|
311
|
+
const at = new Date().toISOString();
|
|
312
|
+
const actor = JSON.stringify(actorOf(ctx));
|
|
313
|
+
db.transaction(() => {
|
|
314
|
+
const cards = db.all<{ requirement_id: string; network_id: string; seq: number | null; title: string }>("SELECT requirement_id, network_id, seq, title FROM requirements WHERE project_id = ?1", current.project_id);
|
|
315
|
+
db.run("UPDATE requirements SET project_id = NULL, updated_at = ?2 WHERE project_id = ?1", [current.project_id, at]);
|
|
316
|
+
for (const card of cards) recordRequirementEvents(db, card, actor, [{ kind: "changed", field: "project", old: current.project_id, new: null }], at);
|
|
317
|
+
db.run("DELETE FROM requirement_projects WHERE project_id = ?1", [current.project_id]);
|
|
318
|
+
});
|
|
319
|
+
deleteTaskGrantsForProject(current.project_id);
|
|
320
|
+
return Response.json({ ok: true });
|
|
321
|
+
}
|
|
322
|
+
if (req.method !== "PATCH") return jsonError("not_found", 404);
|
|
323
|
+
let { name, color, sort, archived } = current;
|
|
324
|
+
if ("name" in body) {
|
|
325
|
+
const next = projectName(body.name);
|
|
326
|
+
if (!next) return jsonError("invalid_project_name", 400);
|
|
327
|
+
name = next;
|
|
328
|
+
}
|
|
329
|
+
if ("color" in body) {
|
|
330
|
+
if (typeof body.color !== "string" || !PROJECT_COLOR.test(body.color)) return jsonError("invalid_project_color", 400);
|
|
331
|
+
color = body.color;
|
|
332
|
+
}
|
|
333
|
+
if ("sort" in body) {
|
|
334
|
+
if (!Number.isInteger(body.sort)) return jsonError("invalid_project_sort", 400);
|
|
335
|
+
sort = body.sort as number;
|
|
336
|
+
}
|
|
337
|
+
if ("archived" in body) {
|
|
338
|
+
if (typeof body.archived !== "boolean") return jsonError("invalid_project_archived", 400);
|
|
339
|
+
archived = body.archived ? 1 : 0;
|
|
340
|
+
}
|
|
341
|
+
if (!archived && projectNameTaken(networkId, name, current.project_id)) return jsonError("project_name_taken", 409);
|
|
342
|
+
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]);
|
|
343
|
+
return Response.json({ ok: true, project: projectPublic(db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, current.project_id)!) });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── 标签管理(RFC-038 §9 的取舍:同项目管理) ──
|
|
347
|
+
// 改名 / 合并 / 删除要改写网络里**每一张**带这个标签的卡,包括调用者看不见的;所以只给不受任务范围限制的人
|
|
348
|
+
// (网络 owner / 管理员 / task_access='all' 的成员),scoped 成员一律 403 —— 不做「只改我看得见的那几张」:
|
|
349
|
+
// 那样同一个标签会被拆成两半,而且改动条数会泄露看不见的卡有几张。节点令牌在入口已被拒(tags_write)。
|
|
350
|
+
function canManageTags(ctx: RequirementsRequestContext, networkId: string): boolean {
|
|
351
|
+
if (ctx.isNodeToken || !canWrite(ctx, networkId)) return false;
|
|
352
|
+
return !(taskCaller(ctx) && isTaskScoped(ctx.auth!.userId, networkId));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
|
|
356
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
357
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
358
|
+
if (!canManageTags(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
359
|
+
let body: Record<string, unknown>;
|
|
360
|
+
try { body = await bodyObject(ctx.req); } catch { return jsonError("invalid_json", 400); }
|
|
361
|
+
const op = parseTagOp(body);
|
|
362
|
+
if ("error" in op) return jsonError(op.error, 400);
|
|
363
|
+
const now = new Date().toISOString();
|
|
364
|
+
const actor = JSON.stringify(actorOf(ctx));
|
|
365
|
+
// 读-改-写整个网络的标签在一个事务里:中途失败一张都不改;颜色表跟着一起挪。
|
|
366
|
+
const result = db.transaction(() => {
|
|
367
|
+
const cards = db.all<{ requirement_id: string; network_id: string; seq: number | null; title: string; tags_json: string | null }>(
|
|
368
|
+
"SELECT requirement_id, network_id, seq, title, tags_json FROM requirements WHERE network_id = ?1 AND tags_json IS NOT NULL AND tags_json <> '[]'", networkId,
|
|
369
|
+
);
|
|
370
|
+
const colorOf = (name: string) => db.get<{ color: string }>("SELECT color FROM network_tags WHERE network_id = ?1 AND name = ?2", networkId, name)?.color ?? null;
|
|
371
|
+
const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
|
|
372
|
+
const known = cards.some(card => storedTags(card.tags_json).some(tag => sources.includes(tag))) || sources.some(tag => colorOf(tag) !== null);
|
|
373
|
+
if (!known) return null;
|
|
374
|
+
if (op.op === "color") {
|
|
375
|
+
if (op.color === null) db.run("DELETE FROM network_tags WHERE network_id = ?1 AND name = ?2", [networkId, op.tag]);
|
|
376
|
+
else db.run("INSERT INTO network_tags (network_id, name, color, updated_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT (network_id, name) DO UPDATE SET color = excluded.color, updated_at = excluded.updated_at", [networkId, op.tag, op.color, now]);
|
|
377
|
+
return { affected: 0 };
|
|
378
|
+
}
|
|
379
|
+
let affected = 0;
|
|
380
|
+
for (const card of cards) {
|
|
381
|
+
const next = applyTagOp(storedTags(card.tags_json), op);
|
|
382
|
+
if (!next) continue;
|
|
383
|
+
db.run("UPDATE requirements SET tags_json = ?1, updated_at = ?2, updated_by_json = ?3 WHERE requirement_id = ?4", [JSON.stringify(next), now, actor, card.requirement_id]);
|
|
384
|
+
recordRequirementEvents(db, card, actor, [{ kind: "changed", field: "tags", old: storedTags(card.tags_json), new: next }], now);
|
|
385
|
+
affected++;
|
|
386
|
+
}
|
|
387
|
+
moveTagColors(networkId, op, colorOf, now);
|
|
388
|
+
return { affected };
|
|
389
|
+
});
|
|
390
|
+
if (!result) return jsonError("tag_not_found", 404);
|
|
391
|
+
if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, `requirement_tag_${op.op}`, "requirement_tag", op.op === "rename" || op.op === "merge" ? op.to : op.tag, JSON.stringify({ ...op, affected: result.affected }).slice(0, 1000), undefined, networkId);
|
|
392
|
+
return Response.json({ ok: true, op: op.op, affected: result.affected });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** 改名 / 合并:目标没有颜色就继承第一个有颜色的来源;来源的颜色行删掉。删除:删颜色行。 */
|
|
396
|
+
function moveTagColors(networkId: string, op: TagOp, colorOf: (name: string) => string | null, now: string): void {
|
|
397
|
+
if (op.op === "color") return;
|
|
398
|
+
const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
|
|
399
|
+
if (op.op !== "delete" && colorOf(op.to) === null) {
|
|
400
|
+
const inherited = sources.map(colorOf).find(color => color !== null);
|
|
401
|
+
if (inherited) db.run("INSERT INTO network_tags (network_id, name, color, updated_at) VALUES (?1, ?2, ?3, ?4)", [networkId, op.to, inherited, now]);
|
|
402
|
+
}
|
|
403
|
+
for (const tag of sources) db.run("DELETE FROM network_tags WHERE network_id = ?1 AND name = ?2", [networkId, tag]);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function toPublic(row: Row) {
|
|
407
|
+
return {
|
|
408
|
+
owner: row.owner_json ? JSON.parse(row.owner_json) : null,
|
|
409
|
+
// 负责 Agent(执行者)。字段总在:客户端靠它判断这个 Hub 分不分「负责人 / 负责 Agent」。
|
|
410
|
+
agent_owner: row.agent_owner_json ? JSON.parse(row.agent_owner_json) : null,
|
|
411
|
+
// 描述(markdown)与子任务。字段总在:客户端靠它判断这个 Hub 支不支持。
|
|
412
|
+
description: row.description || "",
|
|
413
|
+
checklist: storedChecklist(row.checklist_json),
|
|
414
|
+
// 项目(可空)。字段总在:客户端靠它判断这个 Hub 有没有项目。项目被删 → 置空;被归档 → 引用保留。
|
|
415
|
+
project_id: row.project_id || null,
|
|
416
|
+
participants: JSON.parse(row.participants_json || '[]'),
|
|
417
|
+
id: row.requirement_id,
|
|
418
|
+
// 短号:每个网络自己的 #1、#2…(不回收)。界面显示 #N;GET / PATCH / MCP 的 id 也收「#N」。旧 App 忽略它。
|
|
419
|
+
seq: row.seq == null ? null : Number(row.seq),
|
|
420
|
+
name: row.title,
|
|
421
|
+
priority: row.priority,
|
|
422
|
+
assignee: row.assignee || "",
|
|
423
|
+
due: row.due_on || "",
|
|
424
|
+
// 开始(可空,甘特图用)。形状同 due;没设 = ""。旧 App 不认识这个字段,忽略即可。
|
|
425
|
+
start: row.start_on || "",
|
|
426
|
+
column: row.column_name,
|
|
427
|
+
createdAt: row.created_at,
|
|
428
|
+
updatedAt: row.updated_at || row.created_at,
|
|
429
|
+
issues: storedIssues(row.issues_json),
|
|
430
|
+
tags: storedTags(row.tags_json),
|
|
431
|
+
// 同步用的外部引用(如 github:owner/repo#123)和链接;归档的卡默认不在列表里(include_archived=1 才有)。
|
|
432
|
+
external_ref: row.external_ref || null,
|
|
433
|
+
external_url: row.external_url || null,
|
|
434
|
+
archived: !!row.archived,
|
|
435
|
+
// 谁建的 / 谁最后改的:{kind:"user"|"node", id}。旧卡只有 created_by(用户 id)。
|
|
436
|
+
created_by: row.created_by_json ? JSON.parse(row.created_by_json) : row.created_by ? { kind: "user", id: row.created_by } : null,
|
|
437
|
+
updated_by: row.updated_by_json ? JSON.parse(row.updated_by_json) : null,
|
|
438
|
+
// 子需求:parent_id(可空)和父卡上的子需求进度(未归档的子需求数 / 其中完成的)。
|
|
439
|
+
parent_id: row.parent_id || null,
|
|
440
|
+
children: { total: Number(row.children_total ?? 0), done: Number(row.children_done ?? 0) },
|
|
441
|
+
// 完成时间:进「完成」列的时刻,移出清空(不在「完成」列 = null)。completedAtApprox = 升级前就完成的卡,
|
|
442
|
+
// 时刻是按 updated_at 补的近似值。completedBy = 谁移进「完成」的(近似值的卡为 null)。旧 App 忽略这三个字段。
|
|
443
|
+
completedAt: row.completed_at || null,
|
|
444
|
+
completedAtApprox: !!row.completed_at_approx,
|
|
445
|
+
completedBy: row.completed_by_json ? JSON.parse(row.completed_by_json) : null,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function bodyObject(req: Request): Promise<Record<string, unknown>> {
|
|
450
|
+
const value = await req.json();
|
|
451
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_json");
|
|
452
|
+
return value as Record<string, unknown>;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function writeNetwork(body: Record<string, unknown>, ctx: RequirementsRequestContext): string | null {
|
|
456
|
+
if (ctx.auth?.networkId) return ctx.auth.networkId;
|
|
457
|
+
if (typeof body.network_id === "string" && body.network_id.trim()) return body.network_id.trim();
|
|
458
|
+
return resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function canWrite(ctx: RequirementsRequestContext, networkId: string | null): boolean {
|
|
462
|
+
// 节点令牌只能写它绑定的那个网络(scope 已强制;这里再核一次,防止别的入口绕过 scope)。
|
|
463
|
+
if (ctx.isNodeToken && (!ctx.auth?.networkId || ctx.auth.networkId !== networkId)) return false;
|
|
464
|
+
if (ctx.isNodeToken) return canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
|
|
465
|
+
// 需求看板是人类协作面:受限成员(只看授权 Agent 的成员)照样能读写卡片,
|
|
466
|
+
// 只是卡片上没授权给他的 Agent 引用会被隐去、也不能被他指派(见 hiddenNodeFilter)。
|
|
467
|
+
return canRestWriteNetworkAsHuman(ctx.auth, networkId, ctx.isAdmin);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ── 多用户 Agent 权限 ──
|
|
471
|
+
// 受限成员看需求卡时:agent_owner / 参与人 / created_by / updated_by 里没授权给他的节点一律隐去;
|
|
472
|
+
// 他写卡时也不能引用这些节点。返回 null = 这个调用者在这个网络里不受限。
|
|
473
|
+
type HiddenNode = ((nodeId: string) => boolean) | null;
|
|
474
|
+
function hiddenNodeFilter(ctx: RequirementsRequestContext, networkId: string): HiddenNode {
|
|
475
|
+
if (!ctx.auth || ctx.isNodeToken || ctx.isAdmin) return null;
|
|
476
|
+
if (!isAgentRestricted(ctx.auth.userId, networkId)) return null;
|
|
477
|
+
const visible = new Set(visibleAgents(ctx.auth.userId, networkId).nodeIds);
|
|
478
|
+
return (nodeId: string) => !visible.has(nodeId);
|
|
479
|
+
}
|
|
480
|
+
function isHiddenRef(ref: unknown, hidden: HiddenNode): boolean {
|
|
481
|
+
if (!hidden || !ref || typeof ref !== "object") return false;
|
|
482
|
+
const r = ref as { kind?: unknown; id?: unknown };
|
|
483
|
+
return r.kind === "node" && typeof r.id === "string" && hidden(r.id);
|
|
484
|
+
}
|
|
485
|
+
// 每个请求一个权限解析器(按网络缓存成员行与项目授权)。
|
|
486
|
+
const permsByCtx = new WeakMap<RequirementsRequestContext, ReturnType<typeof taskPermissionsResolver>>();
|
|
487
|
+
function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
|
|
488
|
+
let perms = permsByCtx.get(ctx);
|
|
489
|
+
if (!perms) permsByCtx.set(ctx, perms = taskPermissionsResolver(taskCaller(ctx)));
|
|
490
|
+
// viewer_can:只对「只看相关任务」的调用者出现,客户端据此画只读锁、藏删除;不出现 = 与今天一样全能(旧 Hub 也不出现)。
|
|
491
|
+
const can = perms(row);
|
|
492
|
+
const pub = can ? { ...toPublic(row), viewer_can: can } : toPublic(row);
|
|
493
|
+
const hidden = hiddenNodeFilter(ctx, row.network_id);
|
|
494
|
+
if (!hidden) return pub;
|
|
495
|
+
return {
|
|
496
|
+
...pub,
|
|
497
|
+
owner: isHiddenRef(pub.owner, hidden) ? null : pub.owner,
|
|
498
|
+
agent_owner: isHiddenRef(pub.agent_owner, hidden) ? null : pub.agent_owner,
|
|
499
|
+
participants: Array.isArray(pub.participants) ? pub.participants.filter((ref: unknown) => !isHiddenRef(ref, hidden)) : pub.participants,
|
|
500
|
+
created_by: isHiddenRef(pub.created_by, hidden) ? null : pub.created_by,
|
|
501
|
+
updated_by: isHiddenRef(pub.updated_by, hidden) ? null : pub.updated_by,
|
|
502
|
+
completedBy: isHiddenRef(pub.completedBy, hidden) ? null : pub.completedBy,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
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, start_on, seq, completed_at, completed_by_json, completed_at_approx, " +
|
|
507
|
+
"(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
|
|
508
|
+
"(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";
|
|
509
|
+
|
|
510
|
+
type PersonRef = { kind: 'user' | 'node'; id: string };
|
|
511
|
+
function personRef(value: unknown, networkId: string, hidden: HiddenNode = null): PersonRef {
|
|
512
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid_person');
|
|
513
|
+
const row = value as Record<string, unknown>;
|
|
514
|
+
if ((row.kind !== 'user' && row.kind !== 'node') || typeof row.id !== 'string' || !row.id.trim()) throw new Error('invalid_person');
|
|
515
|
+
const exists = row.kind === 'user'
|
|
516
|
+
? 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)
|
|
517
|
+
: db.get("SELECT 1 FROM nodes WHERE network_id=?1 AND node_id=?2", networkId, row.id);
|
|
518
|
+
// 没授权的节点与不存在的节点同一个错误,不给受限成员留 node_id 探测差异。
|
|
519
|
+
if (!exists || (row.kind === 'node' && hidden?.(row.id as string))) throw new Error('person_not_in_network');
|
|
520
|
+
return { kind: row.kind, id: row.id };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// 负责人只能是人类,负责 Agent 只能是节点;参与人两种都行。库里旧的节点负责人照样读出来(启动迁移会挪走),
|
|
524
|
+
// 只有这次请求显式写了 owner / agent_owner 才校验种类。
|
|
525
|
+
// 旧客户端兼容(App ≤ 0.2.142 只有一个「负责人」,可以选节点):请求里 owner 是节点、又**没带** agent_owner
|
|
526
|
+
// → 当成设置负责 Agent,负责人清空,照常 200,并回 owner_coerced_to_agent_owner: true。
|
|
527
|
+
// 带了 agent_owner 的就是新客户端,保持严格:owner 是节点 → 400 owner_must_be_human。
|
|
528
|
+
function assignments(body: Record<string, unknown>, networkId: string, current?: Row, hidden: HiddenNode = null) {
|
|
529
|
+
let owner = current?.owner_json ? JSON.parse(current.owner_json) : null;
|
|
530
|
+
let agentOwner = current?.agent_owner_json ? JSON.parse(current.agent_owner_json) : null;
|
|
531
|
+
let coerced: PersonRef | null = null;
|
|
532
|
+
// 受限成员看不见的负责 Agent,他也不能换掉或清空(他读到的是 null,写回 null 不该抹掉别人的指派)。
|
|
533
|
+
const agentOwnerLocked = isHiddenRef(agentOwner, hidden);
|
|
534
|
+
if ('owner' in body) {
|
|
535
|
+
owner = body.owner === null ? null : personRef(body.owner, networkId, hidden);
|
|
536
|
+
if (owner && owner.kind !== 'user') {
|
|
537
|
+
if ('agent_owner' in body) throw new Error('owner_must_be_human');
|
|
538
|
+
coerced = owner;
|
|
539
|
+
agentOwner = owner;
|
|
540
|
+
owner = null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (coerced && agentOwnerLocked) throw new Error('agent_owner_not_granted');
|
|
544
|
+
if ('agent_owner' in body) {
|
|
545
|
+
if (agentOwnerLocked) throw new Error('agent_owner_not_granted');
|
|
546
|
+
agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId, hidden);
|
|
547
|
+
if (agentOwner && agentOwner.kind !== 'node') throw new Error('agent_owner_must_be_agent');
|
|
548
|
+
}
|
|
549
|
+
let participants = current ? JSON.parse(current.participants_json || '[]') : [];
|
|
550
|
+
if ('participants' in body) {
|
|
551
|
+
if (!Array.isArray(body.participants) || body.participants.length > 100) throw new Error('invalid_participants');
|
|
552
|
+
const refs = body.participants.map(value => personRef(value, networkId, hidden));
|
|
553
|
+
// 参与人是整体替换:把受限成员看不见的那些节点原样留下,别让他「保存」时静默删掉。
|
|
554
|
+
const keptHidden = hidden ? participants.filter((ref: unknown) => isHiddenRef(ref, hidden)) : [];
|
|
555
|
+
participants = [...new Map([...keptHidden, ...refs].map((ref: PersonRef) => [`${ref.kind}:${ref.id}`, ref])).values()];
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
coerced,
|
|
559
|
+
ownerJson: owner === null ? null : JSON.stringify(owner),
|
|
560
|
+
agentOwnerJson: agentOwner === null ? null : JSON.stringify(agentOwner),
|
|
561
|
+
participantsJson: JSON.stringify(participants),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 节点令牌(Agent,比如把 GitHub issue 同步进任务的 TM 节点)可用的操作 —— 只在它自己的网络里
|
|
567
|
+
* (令牌绑定的网络,范围由 resolveRestNetworkScope 强制)。删除卡片、管理项目仍然只给人。
|
|
568
|
+
*/
|
|
569
|
+
const NODE_TOKEN_OPERATIONS: ReadonlySet<string> = new Set<string>(["read", "create", "patch", "checklist_item", "upsert", "projects_read"]);
|
|
570
|
+
function operationOf(req: Request, url: URL): string {
|
|
571
|
+
if (/^\/api\/requirements\/projects(\/|$)/.test(url.pathname)) return req.method === "GET" ? "projects_read" : "projects_write";
|
|
572
|
+
if (url.pathname === "/api/requirements/tags/ops") return "tags_write";
|
|
573
|
+
if (req.method === "GET") return "read";
|
|
574
|
+
if (/^\/api\/requirements\/[^/]+\/checklist\/[^/]+$/.test(url.pathname)) return "checklist_item";
|
|
575
|
+
if (url.pathname === "/api/requirements/upsert") return "upsert";
|
|
576
|
+
if (req.method === "DELETE") return "delete";
|
|
577
|
+
if (req.method === "PATCH") return "patch";
|
|
578
|
+
return "create";
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* GET /api/requirements 的条件请求:ETag = 响应体的哈希,`If-None-Match` 对上就回 304 空体。
|
|
583
|
+
* app 每 15 s 轮询整张表(生产 500 行 708 KB / gzip 163 KB,中国 → 美国约 0.65 s 纯传输),
|
|
584
|
+
* 绝大多数轮询里表根本没变。按**响应体**算,所以对这个调用方可见的任何字段变了都会变
|
|
585
|
+
* (同一张表不同成员看到的不同,ETag 也不同)。不带 If-None-Match 的旧客户端照旧拿 200 全量。
|
|
586
|
+
*/
|
|
587
|
+
export function conditionalJson(req: Request, payload: unknown): Response {
|
|
588
|
+
const body = JSON.stringify(payload);
|
|
589
|
+
return conditionalBody(req, body, bodyEtag(body));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function bodyEtag(body: string): string {
|
|
593
|
+
return `W/"${createHash("sha256").update(body).digest("base64url").slice(0, 27)}"`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function conditionalBody(req: Request, body: string, etag: string): Response {
|
|
597
|
+
// private:按用户可见范围生成,不能被共享缓存复用;no-cache:每次都要回来验证,不会拿旧表当新的。
|
|
598
|
+
const headers = { ETag: etag, "Cache-Control": "private, no-cache" };
|
|
599
|
+
if (ifNoneMatchHits(req.headers.get("if-none-match"), etag)) return new Response(null, { status: 304, headers });
|
|
600
|
+
// ETag 就是正文的哈希:同一份正文的 gzip 结果可以复用(http-gzip.ts)。
|
|
601
|
+
return markGzipReusable(new Response(body, { headers: { ...headers, "Content-Type": "application/json;charset=utf-8" } }), etag);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function ifNoneMatchHits(header: string | null, etag: string): boolean {
|
|
605
|
+
if (!header) return false;
|
|
606
|
+
const bare = (tag: string) => tag.trim().replace(/^W\//, "");
|
|
607
|
+
return header.split(",").some(tag => tag.trim() === "*" || bare(tag) === bare(etag));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// tag_ops:有 POST /api/requirements/tags/ops(标签改名 / 合并 / 删除 / 颜色),GET /api/requirements/tags 带 counts / colors / can_manage。
|
|
611
|
+
// search:GET 认 q=(服务端搜索,语义同 App 的任务搜索);paging:认 limit / cursor,响应带 has_more / next_cursor。
|
|
612
|
+
// list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
|
|
613
|
+
// (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
|
|
614
|
+
// events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
|
|
615
|
+
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events"] as const;
|
|
616
|
+
|
|
617
|
+
// ── 子需求 ──
|
|
618
|
+
// parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
|
|
619
|
+
export const MAX_REQUIREMENT_DEPTH = 5;
|
|
620
|
+
function levelOf(id: string): number {
|
|
621
|
+
let level = 1;
|
|
622
|
+
let cur: string | null = id;
|
|
623
|
+
const seen = new Set<string>();
|
|
624
|
+
while (cur) {
|
|
625
|
+
if (seen.has(cur)) return Number.POSITIVE_INFINITY; // 已存在的环(不该有):当作超深,拒绝挂上去
|
|
626
|
+
seen.add(cur);
|
|
627
|
+
const up = db.get<{ parent_id: string | null }>("SELECT parent_id FROM requirements WHERE requirement_id = ?1", cur);
|
|
628
|
+
cur = up?.parent_id ?? null;
|
|
629
|
+
if (cur) level++;
|
|
630
|
+
}
|
|
631
|
+
return level;
|
|
632
|
+
}
|
|
633
|
+
/** 以 id 为根的子树还有几层(叶子 = 0)。 */
|
|
634
|
+
function heightBelow(id: string): number {
|
|
635
|
+
let height = 0;
|
|
636
|
+
let frontier = [id];
|
|
637
|
+
const seen = new Set<string>([id]);
|
|
638
|
+
while (frontier.length && height <= MAX_REQUIREMENT_DEPTH) {
|
|
639
|
+
const next: string[] = [];
|
|
640
|
+
for (const f of frontier) {
|
|
641
|
+
for (const c of db.all<{ requirement_id: string }>("SELECT requirement_id FROM requirements WHERE parent_id = ?1", f)) {
|
|
642
|
+
if (!seen.has(c.requirement_id)) { seen.add(c.requirement_id); next.push(c.requirement_id); }
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (!next.length) break;
|
|
646
|
+
height++;
|
|
647
|
+
frontier = next;
|
|
648
|
+
}
|
|
649
|
+
return height;
|
|
650
|
+
}
|
|
651
|
+
/** 校验要挂的父卡:同网络、存在、不是自己或自己的后代(不成环)、挂上后不超过 5 层。返回错误码或 null。 */
|
|
652
|
+
function parentError(networkId: string, parentId: string, selfId: string | null): string | null {
|
|
653
|
+
const parent = db.get<{ network_id: string; requirement_id: string }>("SELECT network_id, requirement_id FROM requirements WHERE requirement_id = ?1", parentId);
|
|
654
|
+
if (!parent || parent.network_id !== networkId) return "parent_not_found";
|
|
655
|
+
if (selfId) {
|
|
656
|
+
// 从父卡往上走,碰到自己 = 成环
|
|
657
|
+
let cur: string | null = parentId;
|
|
658
|
+
const seen = new Set<string>();
|
|
659
|
+
while (cur && !seen.has(cur)) {
|
|
660
|
+
if (cur === selfId) return "parent_cycle";
|
|
661
|
+
seen.add(cur);
|
|
662
|
+
cur = db.get<{ parent_id: string | null }>("SELECT parent_id FROM requirements WHERE requirement_id = ?1", cur)?.parent_id ?? null;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const depth = levelOf(parentId) + 1 + (selfId ? heightBelow(selfId) : 0);
|
|
666
|
+
return depth > MAX_REQUIREMENT_DEPTH ? "parent_too_deep" : null;
|
|
667
|
+
}
|
|
668
|
+
function parentIdOf(value: unknown): string | null | undefined {
|
|
669
|
+
if (value === null || value === "") return null;
|
|
670
|
+
return typeof value === "string" && /^[A-Za-z0-9_-]{1,200}$/.test(value) ? value : undefined;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ── 谁做的 ──
|
|
674
|
+
// 用户令牌 = {kind:"user", id:user_id};节点令牌 = {kind:"node", id:令牌绑定的 node_id}(老令牌没绑 node_id 时按
|
|
675
|
+
// 令牌名里的 alias 在本网络找;都找不到记 token 本身,不冒充任何节点)。写进 created_by / updated_by。
|
|
676
|
+
type Actor = { kind: "user" | "node"; id: string };
|
|
677
|
+
function actorOf(ctx: RequirementsRequestContext): Actor | null {
|
|
678
|
+
if (!ctx.auth) return null;
|
|
679
|
+
if (!ctx.isNodeToken) return { kind: "user", id: ctx.auth.userId };
|
|
680
|
+
const tokenId = ctx.auth.tokenId ?? null;
|
|
681
|
+
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;
|
|
682
|
+
if (bound) return { kind: "node", id: bound };
|
|
683
|
+
const alias = ctx.auth.tokenName?.startsWith("node:") ? ctx.auth.tokenName.slice(5) : null;
|
|
684
|
+
if (alias && ctx.auth.networkId) {
|
|
685
|
+
const node = db.get<{ node_id: string }>("SELECT node_id FROM nodes WHERE network_id = ?1 AND alias = ?2", ctx.auth.networkId, alias);
|
|
686
|
+
if (node) return { kind: "node", id: node.node_id };
|
|
687
|
+
}
|
|
688
|
+
return { kind: "node", id: `token:${tokenId ?? "unknown"}` };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ── 外部引用(同步用) ──
|
|
692
|
+
// external_ref:如 github:owner/repo#123,同一网络里唯一(部分唯一索引,见 requirements-migrate.ts)。
|
|
693
|
+
// external_url:http(s) 链接,给界面显示成「在 GitHub 打开」。
|
|
694
|
+
const EXTERNAL_REF = /^[A-Za-z0-9][A-Za-z0-9_.:\/#@+-]{0,199}$/;
|
|
695
|
+
function externalRef(value: unknown): string | null | undefined {
|
|
696
|
+
if (value === null) return null;
|
|
697
|
+
if (typeof value !== "string") return undefined;
|
|
698
|
+
const v = value.trim();
|
|
699
|
+
return EXTERNAL_REF.test(v) ? v : undefined;
|
|
700
|
+
}
|
|
701
|
+
function externalUrl(value: unknown): string | null | undefined {
|
|
702
|
+
if (value === null || value === "") return null;
|
|
703
|
+
if (typeof value !== "string") return undefined;
|
|
704
|
+
const v = value.trim();
|
|
705
|
+
return /^https?:\/\/[^\s]{1,500}$/i.test(v) ? v : undefined;
|
|
706
|
+
}
|
|
707
|
+
function rowByExternalRef(networkId: string, ref: string): Row | undefined {
|
|
708
|
+
return db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND external_ref = ?2`, networkId, ref) ?? undefined;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ── 新建 ──
|
|
712
|
+
async function createRequirement(ctx: RequirementsRequestContext, body: Record<string, unknown>): Promise<Response> {
|
|
713
|
+
const networkId = writeNetwork(body, ctx);
|
|
714
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
715
|
+
if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
716
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
717
|
+
if (!name || name.length > 80) return jsonError("invalid_name", 400);
|
|
718
|
+
const priority = typeof body.priority === "string" ? body.priority : "normal";
|
|
719
|
+
if (!PRIORITIES.has(priority)) return jsonError("invalid_priority", 400);
|
|
720
|
+
const due = typeof body.due === "string" ? normalizeDue(body.due) : "";
|
|
721
|
+
if (due === null) return jsonError("invalid_due", 400);
|
|
722
|
+
const start = typeof body.start === "string" ? normalizeDue(body.start) : "";
|
|
723
|
+
if (start === null) return jsonError("invalid_start", 400);
|
|
724
|
+
const assignee = typeof body.assignee === "string" ? body.assignee.trim().slice(0, 80) : "";
|
|
725
|
+
const clientId = typeof body.client_id === "string" ? body.client_id.trim() : "";
|
|
726
|
+
if (clientId && !/^[A-Za-z0-9._-]{1,80}$/.test(clientId)) return jsonError("invalid_client_id", 400);
|
|
727
|
+
const column = typeof body.column === "string" && COLUMNS.has(body.column) ? body.column : "pool";
|
|
728
|
+
const issues = body.issues === undefined ? [] : normalizeIssues(body.issues);
|
|
729
|
+
if (issues === null) return jsonError("invalid_issues", 400);
|
|
730
|
+
const tags = body.tags === undefined ? [] : normalizeTags(body.tags);
|
|
731
|
+
if (tags === null) return jsonError("invalid_tags", 400);
|
|
732
|
+
const description = body.description === undefined ? "" : normalizeDescription(body.description);
|
|
733
|
+
if (description === null) return jsonError("invalid_description", 400);
|
|
734
|
+
const checklist = body.checklist === undefined ? [] : normalizeChecklist(body.checklist);
|
|
735
|
+
if (checklist === null) return jsonError("invalid_checklist", 400);
|
|
736
|
+
const caller = taskCaller(ctx);
|
|
737
|
+
const scoped = !!caller && isTaskScoped(caller.userId, networkId);
|
|
738
|
+
let projectId: string | null = null;
|
|
739
|
+
if (body.project_id !== undefined) {
|
|
740
|
+
try { projectId = projectRef(body.project_id, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
741
|
+
// scoped 成员只能建进自己 can_edit 的项目;没授权的项目与不存在的项目同一个错误。
|
|
742
|
+
if (!canUseProject(caller, networkId, projectId)) return jsonError("project_not_in_network", 400);
|
|
743
|
+
}
|
|
744
|
+
const ref = body.external_ref === undefined ? null : externalRef(body.external_ref);
|
|
745
|
+
if (ref === undefined) return jsonError("invalid_external_ref", 400);
|
|
746
|
+
// scoped 成员不能带 external_ref:「external_ref_exists」会把别人的卡号回给他。
|
|
747
|
+
if (ref && scoped) return jsonError("external_ref_not_allowed", 403);
|
|
748
|
+
const extUrl = body.external_url === undefined ? null : externalUrl(body.external_url);
|
|
749
|
+
if (extUrl === undefined) return jsonError("invalid_external_url", 400);
|
|
750
|
+
const parentId = body.parent_id === undefined ? null : parentIdOf(body.parent_id);
|
|
751
|
+
if (parentId === undefined) return jsonError("invalid_parent_id", 400);
|
|
752
|
+
if (parentId) {
|
|
753
|
+
const err = parentHidden(ctx, parentId) ? "parent_not_found" : parentError(networkId, parentId, null);
|
|
754
|
+
if (err) return jsonError(err, 400);
|
|
755
|
+
}
|
|
756
|
+
if (ref) {
|
|
757
|
+
const existing = rowByExternalRef(networkId, ref);
|
|
758
|
+
// 同一个外部条目再建一次 = 冲突,回已有的 id(同步方改用 upsert,或按这个 id PATCH)。
|
|
759
|
+
if (existing) return Response.json({ ok: false, error: "external_ref_exists", existing_id: existing.requirement_id }, { status: 409 });
|
|
760
|
+
}
|
|
761
|
+
if (clientId) {
|
|
762
|
+
const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
|
|
763
|
+
// 重放只回调用者看得见的卡;撞上别人的卡(scoped 看不见)= 固定的 409,不把那张卡回给他。
|
|
764
|
+
if (existing) return canSeeTask(caller, existing) ? Response.json({ ok: true, requirement: toPublicFor(ctx, existing) }) : jsonError("client_id_taken", 409);
|
|
765
|
+
}
|
|
766
|
+
const id = `req_${crypto.randomUUID()}`;
|
|
767
|
+
const createdAt = new Date().toISOString();
|
|
768
|
+
let people;
|
|
769
|
+
try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId)); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
770
|
+
const actor = JSON.stringify(actorOf(ctx));
|
|
771
|
+
try {
|
|
772
|
+
// 领号、插入、记「新建」这条动态在同一个事务里:插入被唯一索引挡下时,号和动态一起回滚,不留空洞。
|
|
773
|
+
db.transaction(() => {
|
|
774
|
+
const seq = nextRequirementSeq(db, networkId);
|
|
775
|
+
db.run(
|
|
776
|
+
`INSERT INTO requirements
|
|
777
|
+
(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, start_on, seq, completed_at, completed_by_json, completed_at_approx)
|
|
778
|
+
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, ?23, ?24, ?25, ?26, 0)`,
|
|
779
|
+
[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), start || null, seq,
|
|
780
|
+
// 直接建在「完成」列 = 此刻完成、由建卡的人完成。
|
|
781
|
+
column === "done" ? createdAt : null, column === "done" ? actor : null],
|
|
782
|
+
);
|
|
783
|
+
recordRequirementEvents(db, { requirement_id: id, network_id: networkId, seq, title: name }, actor, [{ kind: "created", field: null, old: null, new: { title: name, column } }], createdAt);
|
|
784
|
+
});
|
|
785
|
+
} catch {
|
|
786
|
+
// 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
|
|
787
|
+
if (ref) {
|
|
788
|
+
const existing = rowByExternalRef(networkId, ref);
|
|
789
|
+
if (existing) return Response.json({ ok: false, error: "external_ref_exists", existing_id: existing.requirement_id }, { status: 409 });
|
|
790
|
+
}
|
|
791
|
+
if (!clientId) return jsonError("insert_failed", 500);
|
|
792
|
+
const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
|
|
793
|
+
if (!existing) return jsonError("insert_failed", 500);
|
|
794
|
+
if (!canSeeTask(caller, existing)) return jsonError("client_id_taken", 409);
|
|
795
|
+
return Response.json({ ok: true, requirement: toPublicFor(ctx, existing) });
|
|
796
|
+
}
|
|
797
|
+
const created = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, id)!;
|
|
798
|
+
return Response.json(withLegacyOwner(toPublicFor(ctx, created), people.coerced), { status: 201 });
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ── 修改(省略的字段保留原值) ──
|
|
802
|
+
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", "start"];
|
|
803
|
+
function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Record<string, unknown>): Response {
|
|
804
|
+
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
|
|
805
|
+
const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
|
|
806
|
+
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
807
|
+
const caller = taskCaller(ctx);
|
|
808
|
+
if (!canEditTask(caller, row)) {
|
|
809
|
+
// 参与人:只放状态和检查项;别的字段点名拒绝。不是参与人 → 与今天一样。
|
|
810
|
+
if (!canParticipantEditTask(caller, row)) return taskDenied(ctx, row, "task_read_only");
|
|
811
|
+
const other = PATCH_FIELDS.find(k => has(k) && !PARTICIPANT_EDIT_FIELDS.includes(k));
|
|
812
|
+
if (other) return taskDenied(ctx, row, "task_read_only", other);
|
|
813
|
+
}
|
|
814
|
+
// scoped 成员不能写 external_ref:唯一索引冲突会泄露别的卡用了这个 ref。
|
|
815
|
+
if (has("external_ref") && caller && isTaskScoped(caller.userId, row.network_id)) return jsonError("external_ref_not_allowed", 403);
|
|
816
|
+
if (has("column") && !COLUMNS.has(String(body.column))) return jsonError("invalid_column", 400);
|
|
817
|
+
const issues = has("issues") ? normalizeIssues(body.issues) : null;
|
|
818
|
+
if (has("issues") && issues === null) return jsonError("invalid_issues", 400);
|
|
819
|
+
const tags = has("tags") ? normalizeTags(body.tags) : null;
|
|
820
|
+
if (has("tags") && tags === null) return jsonError("invalid_tags", 400);
|
|
821
|
+
const description = has("description") ? normalizeDescription(body.description) : null;
|
|
822
|
+
if (has("description") && description === null) return jsonError("invalid_description", 400);
|
|
823
|
+
const checklist = has("checklist") ? normalizeChecklist(body.checklist) : null;
|
|
824
|
+
if (has("checklist") && checklist === null) return jsonError("invalid_checklist", 400);
|
|
825
|
+
let name = row.title;
|
|
826
|
+
if (has("name")) {
|
|
827
|
+
name = typeof body.name === "string" ? body.name.trim() : "";
|
|
828
|
+
if (!name || name.length > 80) return jsonError("invalid_name", 400);
|
|
829
|
+
}
|
|
830
|
+
let priority = row.priority;
|
|
831
|
+
if (has("priority")) {
|
|
832
|
+
if (typeof body.priority !== "string" || !PRIORITIES.has(body.priority)) return jsonError("invalid_priority", 400);
|
|
833
|
+
priority = body.priority;
|
|
834
|
+
}
|
|
835
|
+
let due = row.due_on || "";
|
|
836
|
+
if (has("due")) {
|
|
837
|
+
if (typeof body.due !== "string") return jsonError("invalid_due", 400);
|
|
838
|
+
const next = normalizeDue(body.due);
|
|
839
|
+
if (next === null) return jsonError("invalid_due", 400);
|
|
840
|
+
due = next;
|
|
841
|
+
}
|
|
842
|
+
let start = row.start_on || "";
|
|
843
|
+
if (has("start")) {
|
|
844
|
+
if (typeof body.start !== "string") return jsonError("invalid_start", 400);
|
|
845
|
+
const next = normalizeDue(body.start);
|
|
846
|
+
if (next === null) return jsonError("invalid_start", 400);
|
|
847
|
+
start = next;
|
|
848
|
+
}
|
|
849
|
+
let assignee = row.assignee || "";
|
|
850
|
+
if (has("assignee")) {
|
|
851
|
+
if (typeof body.assignee !== "string") return jsonError("invalid_assignee", 400);
|
|
852
|
+
assignee = body.assignee.trim().slice(0, 80);
|
|
853
|
+
}
|
|
854
|
+
let people;
|
|
855
|
+
try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id)); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
856
|
+
let projectId = row.project_id;
|
|
857
|
+
if (has("project_id")) {
|
|
858
|
+
try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
859
|
+
// scoped 成员只能挪进自己 can_edit 的项目;没授权的项目与不存在的项目同一个错误。
|
|
860
|
+
if (projectId !== row.project_id && !canUseProject(caller, row.network_id, projectId)) return jsonError("project_not_in_network", 400);
|
|
861
|
+
}
|
|
862
|
+
let ref = row.external_ref;
|
|
863
|
+
if (has("external_ref")) {
|
|
864
|
+
const next = externalRef(body.external_ref);
|
|
865
|
+
if (next === undefined) return jsonError("invalid_external_ref", 400);
|
|
866
|
+
if (next && next !== row.external_ref) {
|
|
867
|
+
const taken = rowByExternalRef(row.network_id, next);
|
|
868
|
+
if (taken) return Response.json({ ok: false, error: "external_ref_exists", existing_id: taken.requirement_id }, { status: 409 });
|
|
869
|
+
}
|
|
870
|
+
ref = next;
|
|
871
|
+
}
|
|
872
|
+
let extUrl = row.external_url;
|
|
873
|
+
if (has("external_url")) {
|
|
874
|
+
const next = externalUrl(body.external_url);
|
|
875
|
+
if (next === undefined) return jsonError("invalid_external_url", 400);
|
|
876
|
+
extUrl = next;
|
|
877
|
+
}
|
|
878
|
+
let parentId = row.parent_id;
|
|
879
|
+
if (has("parent_id")) {
|
|
880
|
+
const next = parentIdOf(body.parent_id);
|
|
881
|
+
if (next === undefined) return jsonError("invalid_parent_id", 400);
|
|
882
|
+
if (next) {
|
|
883
|
+
const err = parentHidden(ctx, next) ? "parent_not_found" : parentError(row.network_id, next, row.requirement_id);
|
|
884
|
+
if (err) return jsonError(err, 400);
|
|
885
|
+
}
|
|
886
|
+
parentId = next;
|
|
887
|
+
}
|
|
888
|
+
let archived = row.archived ? 1 : 0;
|
|
889
|
+
if (has("archived")) {
|
|
890
|
+
if (typeof body.archived !== "boolean") return jsonError("invalid_archived", 400);
|
|
891
|
+
archived = body.archived ? 1 : 0;
|
|
892
|
+
}
|
|
893
|
+
const updatedAt = new Date().toISOString();
|
|
894
|
+
const column = has("column") ? String(body.column) : row.column_name;
|
|
895
|
+
const actor = JSON.stringify(actorOf(ctx));
|
|
896
|
+
// 完成时间:移进「完成」= 此刻 + 这次的操作者;移出 = 清空;留在「完成」(done → done、改别的字段)= 原样不动。
|
|
897
|
+
const completed = column !== "done" ? { at: null, by: null, approx: 0 }
|
|
898
|
+
: row.column_name !== "done" ? { at: updatedAt, by: actor, approx: 0 }
|
|
899
|
+
: { at: row.completed_at, by: row.completed_by_json, approx: row.completed_at_approx ? 1 : 0 };
|
|
900
|
+
// 改卡和记动态(改前 row → 改后 updated 的字段差异)在同一个事务里:要么都在,要么都不在。
|
|
901
|
+
const updated = db.transaction(() => {
|
|
902
|
+
db.run(
|
|
903
|
+
`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,
|
|
904
|
+
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, start_on = ?21,
|
|
905
|
+
completed_at = ?22, completed_by_json = ?23, completed_at_approx = ?24
|
|
906
|
+
WHERE requirement_id = ?3`,
|
|
907
|
+
[column, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, has("issues") ? JSON.stringify(issues) : row.issues_json, name, priority, due || null, assignee, people.agentOwnerJson,
|
|
908
|
+
has("description") ? (description || null) : row.description, has("checklist") ? JSON.stringify(checklist) : row.checklist_json, projectId, ref, extUrl, archived, actor, parentId, has("tags") ? JSON.stringify(tags) : row.tags_json, start || null,
|
|
909
|
+
completed.at, completed.by, completed.approx],
|
|
910
|
+
);
|
|
911
|
+
const after = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
912
|
+
recordRequirementEvents(db, after, actor, diffRequirement(row, after), updatedAt);
|
|
913
|
+
return after;
|
|
914
|
+
});
|
|
915
|
+
notifyIfParticipant(ctx, row, updated);
|
|
916
|
+
return Response.json(withLegacyOwner(toPublicFor(ctx, updated), people.coerced));
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* 被兼容改写的请求(旧客户端):响应里的 owner 按旧客户端的理解回显那个节点(它会拿响应核对「保存生效没有」,
|
|
921
|
+
* 0.2.142 的核对不通过就报「这个 Hub 还不能修改」),真实存储是 owner 空、agent_owner = 节点;agent_owner 也一并返回。
|
|
922
|
+
* GET 不这么做:新 App 读 owner / agent_owner 两个字段,GET 回显会让新 App 把 Agent 当成负责人。
|
|
923
|
+
*/
|
|
924
|
+
function withLegacyOwner(requirement: ReturnType<typeof toPublic>, coerced: PersonRef | null) {
|
|
925
|
+
if (!coerced) return { ok: true, requirement };
|
|
926
|
+
return { ok: true, requirement: { ...requirement, owner: coerced }, owner_coerced_to_agent_owner: true };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** 「#123」→ 123;别的形状(包括裸数字)→ null,仍按 requirement_id 查。 */
|
|
930
|
+
export function seqFromId(id: string): number | null {
|
|
931
|
+
const m = /^#([1-9]\d{0,14})$/.exec(id.trim());
|
|
932
|
+
return m ? Number(m[1]) : null;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* 按 id 或「#N」找调用者作用域里的那张卡。短号只在一个网络里唯一:作用域跨多个网络、又各有一张 #N 时
|
|
937
|
+
* 不猜,回 409 ambiguous_seq(带 ?network_id= 再查)。
|
|
938
|
+
*/
|
|
939
|
+
function scopedRow(ctx: RequirementsRequestContext, id: string): Row | Response {
|
|
940
|
+
const seq = seqFromId(id);
|
|
941
|
+
const params: unknown[] = [seq ?? id];
|
|
942
|
+
let sql = `SELECT ${SELECT} FROM requirements WHERE ${seq === null ? "requirement_id" : "seq"} = ?1`;
|
|
943
|
+
sql = addHumanNetworkScope(sql, params, ctx.scope);
|
|
944
|
+
// 看不见的卡与不存在的卡同一个 404(逐字节相同);#N 的 ambiguous_seq 也只在看得见的行里判定。
|
|
945
|
+
sql = addTaskVisibilityScope(sql, params, taskCaller(ctx));
|
|
946
|
+
const rows = db.all<Row>(`${sql} LIMIT 2`, ...params);
|
|
947
|
+
if (rows.length > 1) {
|
|
948
|
+
const networks = rows.map(r => r.network_id);
|
|
949
|
+
return Response.json({ ok: false, error: "ambiguous_seq", networks, message: `#${seq} exists in more than one network you can see (${networks.join(", ")}); pass network_id to pick one` }, { status: 409 });
|
|
950
|
+
}
|
|
951
|
+
return rows[0] ?? jsonError("requirement_not_found", 404);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** GET 的筛选(同步方 / MCP 用):seq(短号)、status、project_id、owner / agent_owner(kind:id)、updated_since、external_ref、parent_id / top_level、include_archived。 */
|
|
955
|
+
function listFilters(url: URL, sql: string, params: unknown[], ctx?: RequirementsRequestContext): string | Response {
|
|
956
|
+
const q = url.searchParams;
|
|
957
|
+
const status = q.get("status");
|
|
958
|
+
if (status !== null) {
|
|
959
|
+
if (!COLUMNS.has(status)) return jsonError("invalid_status", 400);
|
|
960
|
+
sql += ` AND column_name = ?${params.push(status)}`;
|
|
961
|
+
}
|
|
962
|
+
const project = q.get("project_id");
|
|
963
|
+
if (project !== null) sql += project === "none" ? " AND project_id IS NULL" : ` AND project_id = ?${params.push(project)}`;
|
|
964
|
+
for (const [param, column] of [["owner", "owner_json"], ["agent_owner", "agent_owner_json"]] as const) {
|
|
965
|
+
const v = q.get(param);
|
|
966
|
+
if (v === null) continue;
|
|
967
|
+
if (v === "none") { sql += ` AND ${column} IS NULL`; continue; }
|
|
968
|
+
const m = /^(user|node):(.+)$/.exec(v);
|
|
969
|
+
if (!m) return jsonError(`invalid_${param}`, 400);
|
|
970
|
+
// 受限成员按一个没授权给他的节点筛选:与「没有这样的卡」同一结果(否则是「谁负责这张卡」的探测器)。
|
|
971
|
+
if (m[1] === "node" && ctx && hiddenFilterForScope(ctx, m[2])) { sql += " AND 1=0"; continue; }
|
|
972
|
+
sql += ` AND ${column} = ?${params.push(JSON.stringify({ kind: m[1], id: m[2] }))}`;
|
|
973
|
+
}
|
|
974
|
+
const since = q.get("updated_since");
|
|
975
|
+
if (since !== null) {
|
|
976
|
+
const ms = Date.parse(since);
|
|
977
|
+
if (!Number.isFinite(ms)) return jsonError("invalid_updated_since", 400);
|
|
978
|
+
sql += ` AND updated_at >= ?${params.push(new Date(ms).toISOString())}`;
|
|
979
|
+
}
|
|
980
|
+
const seq = q.get("seq");
|
|
981
|
+
if (seq !== null) {
|
|
982
|
+
if (!/^[1-9]\d{0,14}$/.test(seq)) return jsonError("invalid_seq", 400);
|
|
983
|
+
sql += ` AND seq = ?${params.push(Number(seq))}`;
|
|
984
|
+
}
|
|
985
|
+
const ref = q.get("external_ref");
|
|
986
|
+
if (ref !== null) sql += ` AND external_ref = ?${params.push(ref)}`;
|
|
987
|
+
const parent = q.get("parent_id");
|
|
988
|
+
if (parent !== null) sql += parent === "none" ? " AND parent_id IS NULL" : ` AND parent_id = ?${params.push(parent)}`;
|
|
989
|
+
if (q.get("top_level") === "1") sql += " AND parent_id IS NULL";
|
|
990
|
+
// Explicit archived-only wins over the legacy include-all switch.
|
|
991
|
+
// changes=1 看得见「被归档」这一改动:默认连归档的一起回(行上 archived: true),客户端据此把它移出看板。
|
|
992
|
+
if (q.get("archived") === "true") sql += " AND COALESCE(archived, 0) = 1";
|
|
993
|
+
else if (q.get("include_archived") !== "1" && q.get("changes") !== "1") sql += " AND COALESCE(archived, 0) = 0";
|
|
994
|
+
return sql;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const LIST_ORDER = " ORDER BY created_at DESC, requirement_id DESC";
|
|
998
|
+
type LightRow = { requirement_id: string; seq?: number | null; network_id: string; created_at: string; title: string; description: string | null; assignee: string | null; tags_json: string | null; project_id: string | null; owner_json: string | null; agent_owner_json: string | null; participants_json: string | null };
|
|
999
|
+
const parseRef = (json: string | null) => { try { return json ? JSON.parse(json) : null; } catch { return null; } };
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* 列表的一页(from = listFilters 拼好的「FROM requirements WHERE …」)。没有搜索词:SQL 直接 LIMIT。有搜索词:先按同样的筛选 + 顺序读轻量列(不算子需求计数),
|
|
1003
|
+
* 在这里逐行匹配,凑够 limit + 1 张就停,再按 id 读这一页的完整行。
|
|
1004
|
+
*/
|
|
1005
|
+
function listPage(ctx: RequirementsRequestContext, from: string, baseParams: unknown[], lq: ListQuery): { rows: Row[]; hasMore: boolean; nextCursor: string | null } {
|
|
1006
|
+
const params = [...baseParams];
|
|
1007
|
+
let where = from;
|
|
1008
|
+
if (lq.cursor) {
|
|
1009
|
+
const a = params.push(lq.cursor.createdAt), b = params.push(lq.cursor.id);
|
|
1010
|
+
where += ` AND (created_at < ?${a} OR (created_at = ?${a} AND requirement_id < ?${b}))`;
|
|
1011
|
+
}
|
|
1012
|
+
let rows: Row[];
|
|
1013
|
+
if (!lq.terms.length) {
|
|
1014
|
+
rows = db.all<Row>(`SELECT ${SELECT} ${where}${LIST_ORDER} LIMIT ${lq.limit + 1}`, ...params);
|
|
1015
|
+
} else {
|
|
1016
|
+
// seq(任务短号,#2139):启动迁移 ensureRequirementSeq 在 SQLite / PostgreSQL 上都加这一列,SELECT 本来就带它。
|
|
1017
|
+
const light = db.all<LightRow>(`SELECT requirement_id, seq, network_id, created_at, title, description, assignee, tags_json, project_id, owner_json, agent_owner_json, participants_json ${where}${LIST_ORDER}`, ...params);
|
|
1018
|
+
const maps = new Map<string, NameMaps>();
|
|
1019
|
+
const hits: string[] = [];
|
|
1020
|
+
for (const r of light) {
|
|
1021
|
+
let m = maps.get(r.network_id);
|
|
1022
|
+
if (!m) maps.set(r.network_id, m = searchNameMaps(ctx, r.network_id));
|
|
1023
|
+
const row = { name: r.title, description: r.description || "", assignee: r.assignee || "", tags: storedTags(r.tags_json), project_id: r.project_id, owner: parseRef(r.owner_json), agent_owner: parseRef(r.agent_owner_json), participants: parseRef(r.participants_json) ?? [] };
|
|
1024
|
+
if (matchesTaskId(r, lq.idQuery) || matchesTerms(row, lq.terms, m)) hits.push(r.requirement_id);
|
|
1025
|
+
if (hits.length > lq.limit) break;
|
|
1026
|
+
}
|
|
1027
|
+
const byId = new Map(hits.length ? db.all<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id IN (${hits.map((_, i) => `?${i + 1}`).join(",")})`, ...hits).map(r => [r.requirement_id, r]) : []);
|
|
1028
|
+
rows = hits.map(id => byId.get(id)).filter((r): r is Row => !!r);
|
|
1029
|
+
}
|
|
1030
|
+
const hasMore = rows.length > lq.limit;
|
|
1031
|
+
if (hasMore) rows = rows.slice(0, lq.limit);
|
|
1032
|
+
const last = rows[rows.length - 1];
|
|
1033
|
+
return { rows, hasMore, nextCursor: hasMore && last ? encodeCursor({ createdAt: last.created_at, id: last.requirement_id }) : null };
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** 搜索用的名字表(与 GET /api/requirements/people 同一个显示名规则);调用者看不见的节点不放进来。 */
|
|
1037
|
+
function searchNameMaps(ctx: RequirementsRequestContext, networkId: string): NameMaps {
|
|
1038
|
+
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
1039
|
+
const people = new Map<string, string>();
|
|
1040
|
+
for (const u of db.all<{ id: string; name: string }>("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", networkId)) people.set(`user:${u.id}`, u.name);
|
|
1041
|
+
for (const n of db.all<{ id: string; name: string }>("SELECT node_id AS id, COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name FROM nodes WHERE network_id=?1", networkId)) if (!hidden?.(n.id)) people.set(`node:${n.id}`, n.name);
|
|
1042
|
+
const projects = new Map(db.all<{ project_id: string; name: string }>("SELECT project_id, name FROM requirement_projects WHERE network_id=?1", networkId).map(p => [p.project_id, p.name] as const));
|
|
1043
|
+
return { people, projects };
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** 这个节点对调用者在他作用域内的某个受限网络里是否隐藏。 */
|
|
1047
|
+
function hiddenFilterForScope(ctx: RequirementsRequestContext, nodeId: string): boolean {
|
|
1048
|
+
const restricted = ctx.scope.agentRestriction?.networkIds ?? [];
|
|
1049
|
+
for (const networkId of restricted) {
|
|
1050
|
+
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
1051
|
+
if (hidden?.(nodeId)) return true;
|
|
1052
|
+
}
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// ── 列表省流(2026-09-30,App「连接较慢 · 数据可能稍有延迟」)──
|
|
1057
|
+
// 生产:425 张卡、1447 条子任务,整张表 1 MB(gzip 240 KB),任务页每 15 s 读一次;中国经 RELAY 的链路上
|
|
1058
|
+
// 一次要好几秒。三件事,都是加法,旧 App 不带新参数就与原来逐字相同:
|
|
1059
|
+
// 1. view=summary:不带描述正文和子任务条目(打开一张卡再 GET /api/requirements/:id 读全文);
|
|
1060
|
+
// 2. changes=1 + updated_since:只回这之后改过的卡(含归档的),加上删掉的卡的 id 和下次用的 server_time;
|
|
1061
|
+
// 3. 表没变时不重算:按「调用者 + 查询」缓存上一次的正文和 ETag,需求表一有写入就作废(见 requirementsGeneration)。
|
|
1062
|
+
|
|
1063
|
+
/** view=summary 的一行:去掉 description / checklist,换成 has_description 和 checklist_count。 */
|
|
1064
|
+
function toSummary<T extends { description: string; checklist: ChecklistItem[] }>(pub: T): Omit<T, "description" | "checklist"> & { has_description: boolean; checklist_count: { total: number; done: number } } {
|
|
1065
|
+
const { description, checklist, ...rest } = pub;
|
|
1066
|
+
return { ...rest, has_description: !!description, checklist_count: { total: checklist.length, done: checklist.filter(item => item.done).length } };
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** 墓碑保留多久。changes 的 updated_since 早于这之前,删除就可能漏报 —— 响应里的 tombstones_since 告诉客户端这个下限。 */
|
|
1070
|
+
export const TOMBSTONE_RETENTION_MS = 30 * 86_400_000;
|
|
1071
|
+
|
|
1072
|
+
/** 删卡时留一条墓碑(连同判断「谁看得见」要用的列,受限成员只收到他本来看得见的卡的删除)。 */
|
|
1073
|
+
function recordTombstone(row: Row, deletedAt: string): void {
|
|
1074
|
+
db.run(
|
|
1075
|
+
`INSERT INTO requirement_tombstones (requirement_id, network_id, deleted_at, project_id, owner_json, participants_json, created_by, created_by_json)
|
|
1076
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
|
1077
|
+
ON CONFLICT(requirement_id) DO UPDATE SET deleted_at = excluded.deleted_at`,
|
|
1078
|
+
[row.requirement_id, row.network_id, deletedAt, row.project_id || null, row.owner_json || null, row.participants_json || null, row.created_by || null, row.created_by_json || null],
|
|
1079
|
+
);
|
|
1080
|
+
db.run("DELETE FROM requirement_tombstones WHERE deleted_at < ?1", [new Date(Date.parse(deletedAt) - TOMBSTONE_RETENTION_MS).toISOString()]);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
type TombstoneRow = { requirement_id: string; network_id: string; deleted_at: string; project_id: string | null; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null };
|
|
1084
|
+
|
|
1085
|
+
/** updated_since 之后删掉的、调用者看得见的卡。同 id 又出现在表里的(不该有)不算删除。 */
|
|
1086
|
+
function deletedSince(ctx: RequirementsRequestContext, sinceIso: string): string[] {
|
|
1087
|
+
const params: unknown[] = [sinceIso];
|
|
1088
|
+
let sql = "SELECT requirement_id, network_id, deleted_at, project_id, owner_json, participants_json, created_by, created_by_json FROM requirement_tombstones WHERE deleted_at >= ?1";
|
|
1089
|
+
sql = addHumanNetworkScope(sql, params, ctx.scope);
|
|
1090
|
+
sql += " AND requirement_id NOT IN (SELECT requirement_id FROM requirements) ORDER BY deleted_at, requirement_id";
|
|
1091
|
+
const caller = taskCaller(ctx);
|
|
1092
|
+
return db.all<TombstoneRow>(sql, ...params).filter(row => canSeeTask(caller, row)).map(row => row.requirement_id);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// ── 任务动态(requirement-events.ts)──
|
|
1096
|
+
// GET /api/requirements/events?network_id&since&limit&cursor&requirement_id:一个网络的改动流水,按 id 从新到旧。
|
|
1097
|
+
// 可见范围与列表相同:卡还在 → 按当前这张卡判断调用者看不看得见;删掉了 → 按墓碑(过了墓碑保留期的删除,
|
|
1098
|
+
// 只有不受任务范围限制的调用者看得到)。受限成员看不见的节点:操作者隐去,负责人 / 负责 Agent / 参与人里的隐去,
|
|
1099
|
+
// 隐去之后前后一样的那条整条不回(否则是「这张卡的 Agent 换过」的探测器)。
|
|
1100
|
+
// 按可见性过滤是在读出来之后做的:一页最多扫 EVENTS_SCAN_MAX 条,扫满还没凑够就带 next_cursor 回去,客户端接着翻。
|
|
1101
|
+
const EVENTS_PAGE_DEFAULT = 200;
|
|
1102
|
+
const EVENTS_PAGE_MAX = 500;
|
|
1103
|
+
const EVENTS_SCAN_MAX = 5000;
|
|
1104
|
+
type VisibilityRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null };
|
|
1105
|
+
|
|
1106
|
+
function listEvents(ctx: RequirementsRequestContext): Response {
|
|
1107
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
1108
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
1109
|
+
const q = ctx.url.searchParams;
|
|
1110
|
+
const limit = q.get("limit") === null ? EVENTS_PAGE_DEFAULT : Number(q.get("limit"));
|
|
1111
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > EVENTS_PAGE_MAX) return jsonError("invalid_limit", 400);
|
|
1112
|
+
let since: string | null = null;
|
|
1113
|
+
if (q.get("since") !== null) {
|
|
1114
|
+
const ms = Date.parse(q.get("since")!);
|
|
1115
|
+
if (!Number.isFinite(ms)) return jsonError("invalid_since", 400);
|
|
1116
|
+
since = new Date(ms).toISOString();
|
|
1117
|
+
}
|
|
1118
|
+
const cursor = q.get("cursor");
|
|
1119
|
+
if (cursor !== null && !/^[1-9]\d{0,17}$/.test(cursor)) return jsonError("invalid_cursor", 400);
|
|
1120
|
+
const card = q.get("requirement_id");
|
|
1121
|
+
// server_time 取在读表之前:下次拿它当 since,读的期间写进来的不会漏(>= 会重复回,客户端按 id 去重)。
|
|
1122
|
+
const serverTime = new Date().toISOString();
|
|
1123
|
+
const caller = taskCaller(ctx);
|
|
1124
|
+
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
1125
|
+
const seen = new Map<string, boolean>();
|
|
1126
|
+
const canSee = (id: string): boolean => {
|
|
1127
|
+
if (!caller) return true;
|
|
1128
|
+
let ok = seen.get(id);
|
|
1129
|
+
if (ok === undefined) {
|
|
1130
|
+
const cols = "network_id, owner_json, participants_json, created_by, created_by_json, project_id";
|
|
1131
|
+
const row = db.get<VisibilityRow>(`SELECT ${cols} FROM requirements WHERE requirement_id = ?1`, id)
|
|
1132
|
+
?? db.get<VisibilityRow>(`SELECT ${cols} FROM requirement_tombstones WHERE requirement_id = ?1`, id);
|
|
1133
|
+
seen.set(id, ok = !!row && row.network_id === networkId && canSeeTask(caller, row));
|
|
1134
|
+
}
|
|
1135
|
+
return ok;
|
|
1136
|
+
};
|
|
1137
|
+
const shown = (row: EventRow) => {
|
|
1138
|
+
if (!canSee(row.requirement_id)) return null;
|
|
1139
|
+
const ev = eventPublic(row);
|
|
1140
|
+
if (!hidden) return ev;
|
|
1141
|
+
const mask = (ref: unknown) => (isHiddenRef(ref, hidden) ? null : ref);
|
|
1142
|
+
const masked = { ...ev, actor: mask(ev.actor) };
|
|
1143
|
+
if (ev.field === "owner" || ev.field === "agent_owner") { masked.old = mask(ev.old); masked.new = mask(ev.new); }
|
|
1144
|
+
if (ev.field === "participants") {
|
|
1145
|
+
masked.old = Array.isArray(ev.old) ? ev.old.filter(ref => !isHiddenRef(ref, hidden)) : ev.old;
|
|
1146
|
+
masked.new = Array.isArray(ev.new) ? ev.new.filter(ref => !isHiddenRef(ref, hidden)) : ev.new;
|
|
1147
|
+
}
|
|
1148
|
+
return ev.field && JSON.stringify(masked.old) === JSON.stringify(masked.new) ? null : masked;
|
|
1149
|
+
};
|
|
1150
|
+
const events: ReturnType<typeof eventPublic>[] = [];
|
|
1151
|
+
let before = cursor === null ? null : Number(cursor);
|
|
1152
|
+
let scanned = 0;
|
|
1153
|
+
let nextCursor: string | null = null;
|
|
1154
|
+
scan: for (;;) {
|
|
1155
|
+
const params: unknown[] = [networkId];
|
|
1156
|
+
let sql = "SELECT id, network_id, requirement_id, seq, title, actor_json, kind, field, old_json, new_json, created_at FROM requirement_events WHERE network_id = ?1";
|
|
1157
|
+
if (since) sql += ` AND created_at >= ?${params.push(since)}`;
|
|
1158
|
+
if (card) sql += ` AND requirement_id = ?${params.push(card)}`;
|
|
1159
|
+
if (before !== null) sql += ` AND id < ?${params.push(before)}`;
|
|
1160
|
+
const batch = db.all<EventRow>(`${sql} ORDER BY id DESC LIMIT ${limit + 1}`, ...params);
|
|
1161
|
+
for (const row of batch) {
|
|
1162
|
+
const ev = shown(row);
|
|
1163
|
+
if (ev && events.length === limit) { nextCursor = events[events.length - 1].id; break scan; }
|
|
1164
|
+
scanned++;
|
|
1165
|
+
before = Number(row.id);
|
|
1166
|
+
if (ev) events.push(ev);
|
|
1167
|
+
}
|
|
1168
|
+
if (batch.length < limit + 1) break;
|
|
1169
|
+
// 扫满了(调用者看不见的太多):从扫到的地方接着翻。
|
|
1170
|
+
if (scanned >= EVENTS_SCAN_MAX) { nextCursor = String(before); break; }
|
|
1171
|
+
}
|
|
1172
|
+
return Response.json({ ok: true, network_id: networkId, events, has_more: nextCursor !== null, next_cursor: nextCursor, server_time: serverTime });
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* 需求表的写入代数:每个经过 handleRequirementsRequest 的非 GET 请求处理完就 +1(REST 和 MCP 的写入都走它,
|
|
1177
|
+
* tools.ts 调的是同一个处理函数)。列表缓存只在代数没变时复用。进程重启 = 缓存清空。
|
|
1178
|
+
*/
|
|
1179
|
+
let requirementsGeneration = 0;
|
|
1180
|
+
/** 缓存条目最长信任这么久:兜住不经过处理函数的改动(启动迁移、运维脚本直接改库)。 */
|
|
1181
|
+
export const LIST_CACHE_MAX_AGE_MS = 60_000;
|
|
1182
|
+
/** 只缓存不带搜索词的列表(搜索每打一个字就是一个新键),总共最多这么多字符(条目按最近使用淘汰)。 */
|
|
1183
|
+
const LIST_CACHE_MAX_CHARS = 16 * 1024 * 1024;
|
|
1184
|
+
type ListCacheEntry = { generation: number; acl: string; at: number; body: string; etag: string };
|
|
1185
|
+
const listCache = new Map<string, ListCacheEntry>();
|
|
1186
|
+
let listCacheChars = 0;
|
|
1187
|
+
function rememberList(key: string, entry: ListCacheEntry): void {
|
|
1188
|
+
const old = listCache.get(key);
|
|
1189
|
+
if (old) { listCache.delete(key); listCacheChars -= old.body.length; }
|
|
1190
|
+
if (entry.body.length > LIST_CACHE_MAX_CHARS / 4) return;
|
|
1191
|
+
listCache.set(key, entry);
|
|
1192
|
+
listCacheChars += entry.body.length;
|
|
1193
|
+
for (const [k, v] of listCache) {
|
|
1194
|
+
if (listCacheChars <= LIST_CACHE_MAX_CHARS) break;
|
|
1195
|
+
listCache.delete(k);
|
|
1196
|
+
listCacheChars -= v.body.length;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* 缓存能不能用、以及调用者权限的指纹。null = 这个调用者不缓存:受限成员(看得见哪些 Agent 取决于节点表)
|
|
1202
|
+
* 和「只看相关任务」的成员(看得见哪些卡取决于项目授权)每次照旧现算。
|
|
1203
|
+
* 其余调用者的输出只取决于需求表本身 + 他的角色 / 成员行:这些都放进指纹,变了就不命中。
|
|
1204
|
+
*/
|
|
1205
|
+
function listCacheAcl(ctx: RequirementsRequestContext): string | null {
|
|
1206
|
+
const caller = taskCaller(ctx);
|
|
1207
|
+
if (!caller) return `open:${ctx.isAdmin ? 1 : 0}:${ctx.isNodeToken ? 1 : 0}`;
|
|
1208
|
+
if (restrictedNetworkIds(caller.userId).length > 0) return null;
|
|
1209
|
+
const members = db.all<Record<string, unknown>>("SELECT * FROM network_members WHERE user_id = ?1 ORDER BY network_id", caller.userId);
|
|
1210
|
+
const networks = members.map(m => String(m.network_id));
|
|
1211
|
+
if (networks.some(networkId => isTaskScoped(caller.userId, networkId))) return null;
|
|
1212
|
+
const role = db.get<{ role: string | null }>("SELECT role FROM users WHERE user_id = ?1", caller.userId)?.role ?? null;
|
|
1213
|
+
return JSON.stringify({ role, members });
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function listCacheKey(ctx: RequirementsRequestContext): string {
|
|
1217
|
+
const sorted = [...ctx.url.searchParams.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1218
|
+
return JSON.stringify({ u: ctx.auth?.userId ?? null, n: ctx.auth?.networkId ?? null, a: ctx.isAdmin, t: ctx.isNodeToken, s: ctx.scope, q: sorted });
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/** Test-only. */
|
|
1222
|
+
export function __requirementsListCacheForTest() {
|
|
1223
|
+
return { size: () => listCache.size, clear: () => { listCache.clear(); listCacheChars = 0; }, generation: () => requirementsGeneration };
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
export async function handleRequirementsRequest(ctx: RequirementsRequestContext): Promise<Response | null> {
|
|
1227
|
+
const { url } = ctx;
|
|
1228
|
+
if (url.pathname !== "/api/requirements" && !url.pathname.startsWith("/api/requirements/")) return null;
|
|
1229
|
+
if (ctx.req.method === "GET") return handleRequirementsRequestInner(ctx);
|
|
1230
|
+
// 写完(成功与否)才 +1:之后的 GET 一定看得见这次写入。
|
|
1231
|
+
try { return await handleRequirementsRequestInner(ctx); } finally { requirementsGeneration++; }
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
async function handleRequirementsRequestInner(ctx: RequirementsRequestContext): Promise<Response | null> {
|
|
1235
|
+
const { req, url } = ctx;
|
|
1236
|
+
// 节点令牌只能做 NODE_TOKEN_OPERATIONS 里的事(读 / 建 / 改 / 勾子任务 / upsert / 读项目),
|
|
1237
|
+
// 而且只在它绑定的网络里 —— 范围由 scope 强制,写入再由 canWrite 核一次。
|
|
1238
|
+
if (ctx.isNodeToken && !NODE_TOKEN_OPERATIONS.has(operationOf(req, url))) return jsonError("user_token_required", 403);
|
|
1239
|
+
|
|
1240
|
+
if (url.pathname === "/api/requirements/projects" || url.pathname.startsWith("/api/requirements/projects/")) return handleProjects(ctx);
|
|
1241
|
+
|
|
1242
|
+
if (url.pathname === "/api/requirements/tags" && req.method === "GET") {
|
|
1243
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
1244
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
1245
|
+
const tagParams: unknown[] = [networkId];
|
|
1246
|
+
const tagSql = addTaskVisibilityScope("SELECT tags_json FROM requirements WHERE network_id=?1", tagParams, taskCaller(ctx));
|
|
1247
|
+
const rows = db.all<{ tags_json: string | null }>(tagSql, ...tagParams);
|
|
1248
|
+
// counts:调用者看得见的卡里(含归档)每个标签用了几张;colors:只回这些标签的颜色(看不见的卡上的标签不外泄)。
|
|
1249
|
+
// can_manage:这个调用者能不能用 /tags/ops。旧 App 只读 tags,多出的字段忽略。
|
|
1250
|
+
const counts: Record<string, number> = {};
|
|
1251
|
+
for (const row of rows) for (const tag of storedTags(row.tags_json)) counts[tag] = (counts[tag] ?? 0) + 1;
|
|
1252
|
+
const tags = Object.keys(counts).sort();
|
|
1253
|
+
const colors: Record<string, string> = {};
|
|
1254
|
+
for (const row of db.all<{ name: string; color: string }>("SELECT name, color FROM network_tags WHERE network_id = ?1", networkId)) {
|
|
1255
|
+
if (counts[row.name]) colors[row.name] = row.color;
|
|
1256
|
+
}
|
|
1257
|
+
return Response.json({ ok: true, networkId, tags, counts, colors, can_manage: canManageTags(ctx, networkId) });
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (url.pathname === "/api/requirements/tags/ops") {
|
|
1261
|
+
if (req.method !== "POST") return jsonError("not_found", 404);
|
|
1262
|
+
return handleTagOp(ctx);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// 仪表盘聚合(requirements-stats.ts):一个网络、调用者看得见的卡(与列表同一个可见范围),含归档的卡 ——
|
|
1266
|
+
// 完成的卡常被归档,不算它们「今天完成了多少」就会偏少。
|
|
1267
|
+
if (url.pathname === "/api/requirements/stats" && req.method === "GET") {
|
|
1268
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
1269
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
1270
|
+
const sq = parseStatsQuery(url.searchParams, Date.now());
|
|
1271
|
+
if ("error" in sq) return jsonError(sq.error, 400);
|
|
1272
|
+
const statsParams: unknown[] = [networkId];
|
|
1273
|
+
const statsSql = addTaskVisibilityScope(
|
|
1274
|
+
"SELECT requirement_id, seq, title, project_id, column_name, archived, created_at, completed_at, completed_at_approx, completed_by_json FROM requirements WHERE network_id = ?1",
|
|
1275
|
+
statsParams, taskCaller(ctx),
|
|
1276
|
+
);
|
|
1277
|
+
const rows = db.all<StatsRow>(statsSql, ...statsParams);
|
|
1278
|
+
return conditionalJson(ctx.req, { ok: true, networkId, ...aggregateStats(rows, sq, hiddenNodeFilter(ctx, networkId)) });
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (url.pathname === "/api/requirements/events" && req.method === "GET") return listEvents(ctx);
|
|
1282
|
+
|
|
1283
|
+
if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
|
|
1284
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
1285
|
+
if (!networkId) return jsonError('network_id_required', 400);
|
|
1286
|
+
// name = 显示用的名字(没设 display_name 时回落到用户名 / alias,旧 app 只读它);display_name 单独给出、没设为 ""
|
|
1287
|
+
// —— 分享图等对外场景据此判断「只有用户名」,不把 admin 之类的账号名印出去。
|
|
1288
|
+
// 成员的 display_name 等于用户名也算「没设」:register() 在没给显示名时存的就是 username(auth.ts),
|
|
1289
|
+
// 否则没设过显示名的账号(比如 admin)照样把账号名当显示名送出去。
|
|
1290
|
+
const users = db.all<{ id: string; name: string; display_name: string }>(
|
|
1291
|
+
"SELECT u.user_id AS id, COALESCE(NULLIF(u.display_name,''), u.username) AS name, CASE WHEN u.display_name IS NULL OR u.display_name = '' OR u.display_name = u.username THEN '' ELSE u.display_name END AS display_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,
|
|
1292
|
+
);
|
|
1293
|
+
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
1294
|
+
const nodes = db.all<{ id: string; name: string; display_name: string }>(
|
|
1295
|
+
"SELECT node_id AS id, COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name, COALESCE(display_name,'') AS display_name FROM nodes WHERE network_id=?1 ORDER BY name, id", networkId,
|
|
1296
|
+
).filter(row => !hidden?.(row.id));
|
|
1297
|
+
return Response.json({ ok: true, people: [...users.map(row => ({ ...row, kind: 'user', networkId })), ...nodes.map(row => ({ ...row, kind: 'node', networkId }))] });
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if (url.pathname === "/api/requirements" && req.method === "GET") {
|
|
1301
|
+
const params: unknown[] = [];
|
|
1302
|
+
// 只拼 FROM … WHERE:同一组条件既用来读整行,也用来(搜索时)先读轻量列。
|
|
1303
|
+
let sql = "FROM requirements WHERE 1=1";
|
|
1304
|
+
sql = addHumanNetworkScope(sql, params, ctx.scope);
|
|
1305
|
+
sql = addTaskVisibilityScope(sql, params, taskCaller(ctx));
|
|
1306
|
+
const filtered = listFilters(url, sql, params, ctx);
|
|
1307
|
+
if (typeof filtered !== "string") return filtered;
|
|
1308
|
+
// q= / limit / cursor(requirements-search.ts)。都不带 = 旧行为:最新 500 张、同样的顺序。
|
|
1309
|
+
const lq = parseListQuery(url.searchParams);
|
|
1310
|
+
if ("error" in lq) return jsonError(lq.error, 400);
|
|
1311
|
+
const view = url.searchParams.get("view");
|
|
1312
|
+
if (view !== null && view !== "full" && view !== "summary") return jsonError("invalid_view", 400);
|
|
1313
|
+
const changes = url.searchParams.get("changes") === "1";
|
|
1314
|
+
if (changes && url.searchParams.get("updated_since") === null) return jsonError("updated_since_required", 400);
|
|
1315
|
+
// changes 模式:server_time 取在读表之前,下次拿它当 updated_since,读表期间的写入不会漏(>= 会重复回一次,客户端按 id 覆盖)。
|
|
1316
|
+
const serverTime = new Date().toISOString();
|
|
1317
|
+
const cacheKey = changes || url.searchParams.get("q") ? null : listCacheKey(ctx);
|
|
1318
|
+
const acl = cacheKey ? listCacheAcl(ctx) : null;
|
|
1319
|
+
const generation = requirementsGeneration;
|
|
1320
|
+
const cached = cacheKey && acl !== null ? listCache.get(cacheKey) : undefined;
|
|
1321
|
+
if (cached && cached.generation === generation && cached.acl === acl && Date.now() - cached.at < LIST_CACHE_MAX_AGE_MS) {
|
|
1322
|
+
listCache.delete(cacheKey!);
|
|
1323
|
+
listCache.set(cacheKey!, cached);
|
|
1324
|
+
return conditionalBody(ctx.req, cached.body, cached.etag);
|
|
1325
|
+
}
|
|
1326
|
+
const page = listPage(ctx, filtered, params, lq);
|
|
1327
|
+
const rows = page.rows.map(row => toPublicFor(ctx, row));
|
|
1328
|
+
// capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
|
|
1329
|
+
// has_more / next_cursor:后面还有没有(带 cursor=next_cursor 再读一页)。旧客户端不认识,忽略即可。
|
|
1330
|
+
const payload: Record<string, unknown> = { ok: true, requirements: view === "summary" ? rows.map(toSummary) : rows, capabilities: REQUIREMENT_CAPABILITIES, has_more: page.hasMore, next_cursor: page.nextCursor };
|
|
1331
|
+
if (view === "summary") payload.view = "summary";
|
|
1332
|
+
if (changes) {
|
|
1333
|
+
const since = new Date(Date.parse(url.searchParams.get("updated_since")!)).toISOString();
|
|
1334
|
+
payload.deleted = deletedSince(ctx, since);
|
|
1335
|
+
payload.server_time = serverTime;
|
|
1336
|
+
payload.tombstones_since = new Date(Date.parse(serverTime) - TOMBSTONE_RETENTION_MS).toISOString();
|
|
1337
|
+
}
|
|
1338
|
+
const body = JSON.stringify(payload);
|
|
1339
|
+
const etag = bodyEtag(body);
|
|
1340
|
+
if (cacheKey && acl !== null) rememberList(cacheKey, { generation, acl, at: Date.now(), body, etag });
|
|
1341
|
+
return conditionalBody(ctx.req, body, etag);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (url.pathname === "/api/requirements" && req.method === "POST") {
|
|
1345
|
+
let body: Record<string, unknown>;
|
|
1346
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
1347
|
+
return createRequirement(ctx, body);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// 按 external_ref 建或改(同步用,幂等):同一网络里已有这个 external_ref → 按 PATCH 语义改它
|
|
1351
|
+
// (省略的字段保留,包括状态);没有 → 新建。重复同步同一个 issue 只会改,不会重复建。
|
|
1352
|
+
if (url.pathname === "/api/requirements/upsert" && req.method === "POST") {
|
|
1353
|
+
let body: Record<string, unknown>;
|
|
1354
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
1355
|
+
const ref = externalRef(body.external_ref);
|
|
1356
|
+
if (!ref) return jsonError("external_ref_required", 400);
|
|
1357
|
+
const networkId = writeNetwork(body, ctx);
|
|
1358
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
1359
|
+
if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
1360
|
+
// scoped 成员不能用同步接口:external_ref 的唯一索引会把「这个 ref 已经有卡」泄露出去。固定错误,不看 ref。
|
|
1361
|
+
if (taskCaller(ctx) && isTaskScoped(ctx.auth!.userId, networkId)) return jsonError("upsert_not_allowed", 403);
|
|
1362
|
+
const existing = rowByExternalRef(networkId, ref);
|
|
1363
|
+
if (!existing) {
|
|
1364
|
+
const res = await createRequirement(ctx, body);
|
|
1365
|
+
if (res.status !== 201) return res;
|
|
1366
|
+
const data = await res.json() as Record<string, unknown>;
|
|
1367
|
+
return Response.json({ ...data, created: true }, { status: 201 });
|
|
1368
|
+
}
|
|
1369
|
+
const { external_ref: _ref, network_id: _net, client_id: _client, ...patch } = body;
|
|
1370
|
+
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(patch, k))) return Response.json({ ok: true, requirement: toPublicFor(ctx, existing), created: false });
|
|
1371
|
+
const res = patchRequirement(ctx, existing, patch);
|
|
1372
|
+
if (res.status !== 200) return res;
|
|
1373
|
+
const data = await res.json() as Record<string, unknown>;
|
|
1374
|
+
return Response.json({ ...data, created: false });
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const itemMatch = url.pathname.match(/^\/api\/requirements\/([^/]+)\/checklist\/([^/]+)$/);
|
|
1378
|
+
if (itemMatch) {
|
|
1379
|
+
if (req.method !== "PATCH") return jsonError("not_found", 404);
|
|
1380
|
+
let body: Record<string, unknown>;
|
|
1381
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
1382
|
+
if (typeof body.done !== "boolean") return jsonError("invalid_done", 400);
|
|
1383
|
+
const row = scopedRow(ctx, decodeURIComponent(itemMatch[1]));
|
|
1384
|
+
if (row instanceof Response) return row;
|
|
1385
|
+
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
1386
|
+
if (!canEditTask(taskCaller(ctx), row) && !canParticipantEditTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_read_only");
|
|
1387
|
+
// 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
|
|
1388
|
+
const items = storedChecklist(row.checklist_json);
|
|
1389
|
+
const itemId = decodeURIComponent(itemMatch[2]);
|
|
1390
|
+
const item = items.find(entry => entry.id === itemId);
|
|
1391
|
+
if (!item) return jsonError("checklist_item_not_found", 404);
|
|
1392
|
+
const was = item.done;
|
|
1393
|
+
item.done = body.done;
|
|
1394
|
+
const at = new Date().toISOString();
|
|
1395
|
+
const actor = JSON.stringify(actorOf(ctx));
|
|
1396
|
+
const updated = db.transaction(() => {
|
|
1397
|
+
db.run("UPDATE requirements SET checklist_json = ?1, updated_at = ?2, updated_by_json = ?3 WHERE requirement_id = ?4", [JSON.stringify(items), at, actor, row.requirement_id]);
|
|
1398
|
+
// 重复勾同一个值(done 是显式值,幂等)不算一次改动,不记。
|
|
1399
|
+
if (was !== item.done) recordRequirementEvents(db, row, actor, [{ kind: "changed", field: "checklist_item", old: { ...item, done: was }, new: { ...item } }], at);
|
|
1400
|
+
return db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
1401
|
+
});
|
|
1402
|
+
notifyIfParticipant(ctx, row, updated);
|
|
1403
|
+
return Response.json({ ok: true, requirement: toPublicFor(ctx, updated) });
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
const match = url.pathname.match(/^\/api\/requirements\/([^/]+)$/);
|
|
1407
|
+
if (!match) return jsonError("not_found", 404);
|
|
1408
|
+
const id = decodeURIComponent(match[1]);
|
|
1409
|
+
if (req.method === "GET") {
|
|
1410
|
+
const row = scopedRow(ctx, id);
|
|
1411
|
+
return row instanceof Response ? row : Response.json({ ok: true, requirement: toPublicFor(ctx, row) });
|
|
1412
|
+
}
|
|
1413
|
+
if (req.method === "DELETE") {
|
|
1414
|
+
// 删除只给人(节点令牌在入口已被拒):Agent 用 archived 归档。
|
|
1415
|
+
const row = scopedRow(ctx, id);
|
|
1416
|
+
if (row instanceof Response) return row;
|
|
1417
|
+
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
1418
|
+
if (!canDeleteTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_delete_denied");
|
|
1419
|
+
// 子需求不跟着删:先解挂(变成顶层),再删父卡。
|
|
1420
|
+
const deletedAt = new Date().toISOString();
|
|
1421
|
+
db.transaction(() => {
|
|
1422
|
+
db.run("UPDATE requirements SET parent_id = NULL, updated_at = ?2 WHERE parent_id = ?1", [row.requirement_id, deletedAt]);
|
|
1423
|
+
db.run("DELETE FROM requirements WHERE requirement_id = ?1", [row.requirement_id]);
|
|
1424
|
+
recordTombstone(row, deletedAt);
|
|
1425
|
+
// 卡没了,动态里还要画得出「谁删了 #N 标题」:标题 / 短号随行存(seq、title 列)。
|
|
1426
|
+
recordRequirementEvents(db, row, JSON.stringify(actorOf(ctx)), [{ kind: "deleted", field: null, old: null, new: null }], deletedAt);
|
|
1427
|
+
});
|
|
1428
|
+
// 硬删除以前不留痕(RFC-038 §9.1):记下是谁删了哪张(标题 + 短号),卡本身已经没了。
|
|
1429
|
+
if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, "requirement_deleted", "requirement", row.requirement_id, JSON.stringify({ title: row.title, seq: row.seq ?? null }).slice(0, 1000), undefined, row.network_id);
|
|
1430
|
+
return Response.json({ ok: true, deleted: row.requirement_id });
|
|
1431
|
+
}
|
|
1432
|
+
if (req.method !== "PATCH") return jsonError("not_found", 404);
|
|
1433
|
+
let body: Record<string, unknown>;
|
|
1434
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
1435
|
+
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
|
|
1436
|
+
const row = scopedRow(ctx, id);
|
|
1437
|
+
if (row instanceof Response) return row;
|
|
1438
|
+
return patchRequirement(ctx, row, body);
|
|
1439
|
+
}
|