@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,45 @@
|
|
|
1
|
+
// diffRequirement 的边角(HTTP 层的写路径在 requirement-events-http.test.ts)。
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import { diffRequirement, eventPublic, type EventCard } from "./requirement-events.js";
|
|
4
|
+
|
|
5
|
+
const base: EventCard = {
|
|
6
|
+
requirement_id: "req_1", network_id: "net_1", seq: 7, title: "示例", column_name: "pool", priority: "normal", due_on: null, start_on: null,
|
|
7
|
+
assignee: null, owner_json: null, agent_owner_json: null, participants_json: "[]", tags_json: "[]", checklist_json: "[]", description: null,
|
|
8
|
+
project_id: null, parent_id: null, archived: 0,
|
|
9
|
+
};
|
|
10
|
+
const ck = (items: { id: string; text: string; done?: boolean }[]) => JSON.stringify(items.map(i => ({ done: false, ...i })));
|
|
11
|
+
|
|
12
|
+
describe("diffRequirement", () => {
|
|
13
|
+
test("no change → no events; null vs empty string are the same", () => {
|
|
14
|
+
expect(diffRequirement(base, { ...base })).toEqual([]);
|
|
15
|
+
expect(diffRequirement(base, { ...base, due_on: "", assignee: "", description: "" })).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
test("created carries title and column only", () => {
|
|
18
|
+
expect(diffRequirement(null, base)).toEqual([{ kind: "created", field: null, old: null, new: { title: "示例", column: "pool" } }]);
|
|
19
|
+
});
|
|
20
|
+
test("checklist: reorder, rename, add or remove is one `checklist` event with counts", () => {
|
|
21
|
+
const a = { ...base, checklist_json: ck([{ id: "a", text: "一" }, { id: "b", text: "二", done: true }]) };
|
|
22
|
+
for (const next of [ck([{ id: "b", text: "二", done: true }, { id: "a", text: "一" }]), ck([{ id: "a", text: "一改" }, { id: "b", text: "二", done: true }]), ck([{ id: "a", text: "一" }])]) {
|
|
23
|
+
const out = diffRequirement(a, { ...a, checklist_json: next });
|
|
24
|
+
expect(out.length).toBe(1);
|
|
25
|
+
expect(out[0].field).toBe("checklist");
|
|
26
|
+
}
|
|
27
|
+
expect(diffRequirement(a, { ...a, checklist_json: ck([{ id: "a", text: "一" }]) })[0]).toMatchObject({ old: { total: 2, done: 1 }, new: { total: 1, done: 0 } });
|
|
28
|
+
});
|
|
29
|
+
test("checklist: two items ticked in one write are two `checklist_item` events", () => {
|
|
30
|
+
const a = { ...base, checklist_json: ck([{ id: "a", text: "一" }, { id: "b", text: "二" }]) };
|
|
31
|
+
const out = diffRequirement(a, { ...a, checklist_json: ck([{ id: "a", text: "一", done: true }, { id: "b", text: "二", done: true }]) });
|
|
32
|
+
expect(out.map(e => [e.field, (e.new as { id: string }).id])).toEqual([["checklist_item", "a"], ["checklist_item", "b"]]);
|
|
33
|
+
});
|
|
34
|
+
test("malformed stored JSON reads as empty rather than throwing", () => {
|
|
35
|
+
expect(() => diffRequirement({ ...base, tags_json: "{nope", participants_json: "x" }, base)).not.toThrow();
|
|
36
|
+
});
|
|
37
|
+
test("description records lengths, never text", () => {
|
|
38
|
+
const [e] = diffRequirement(base, { ...base, description: "secret text" });
|
|
39
|
+
expect(e).toEqual({ kind: "changed", field: "description", old: { chars: 0 }, new: { chars: 11 } });
|
|
40
|
+
});
|
|
41
|
+
test("eventPublic: bigint / string ids from PostgreSQL come out as strings, seq as a number", () => {
|
|
42
|
+
const row = { id: 12n as unknown as string, network_id: "n", requirement_id: "r", seq: "3", title: "t", actor_json: null, kind: "changed", field: "priority", old_json: "\"low\"", new_json: "\"high\"", created_at: "2026-10-01T00:00:00.000Z" };
|
|
43
|
+
expect(eventPublic(row)).toMatchObject({ id: "12", seq: 3, old: "low", new: "high", actor: null });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// 任务动态(#429,Vincent 2026-09-30「任务有新的状态…这完成了,这是有这种动态」)。
|
|
2
|
+
//
|
|
3
|
+
// requirements 表只存「现在是什么」和「最后是谁改的」(updated_by_json),改之前是什么没有任何地方留着 ——
|
|
4
|
+
// 「优先级 P2 → P0」「状态 需求池 → 进行中」画不出来。这里给每一次写入记字段级的流水:谁(用户 / 节点)、
|
|
5
|
+
// 什么时候、哪个字段、旧值 → 新值。写入点在 requirements.ts 的各条写路径里,和那次 UPDATE 在同一个事务里。
|
|
6
|
+
//
|
|
7
|
+
// 表只加不改:旧 Hub(回滚之后)不认识这张表,也不会碰它;再升回来接着记(回滚期间的改动没有流水)。
|
|
8
|
+
// 读:GET /api/requirements/events(requirements.ts,可见范围与列表相同),capability `events`。
|
|
9
|
+
import type { DbAdapter } from "./db-adapter.js";
|
|
10
|
+
|
|
11
|
+
/** 流水保留多久。更早的在写入时顺手清掉(每 PRUNE_EVERY 次写一次)。 */
|
|
12
|
+
export const EVENTS_RETENTION_MS = 180 * 86_400_000;
|
|
13
|
+
const PRUNE_EVERY = 500;
|
|
14
|
+
|
|
15
|
+
/** IF NOT EXISTS:重复执行无副作用;SQLite / PostgreSQL 同一段(适配层把 AUTOINCREMENT 译成 BIGSERIAL)。 */
|
|
16
|
+
export function ensureRequirementEvents(database: DbAdapter): void {
|
|
17
|
+
database.exec(`
|
|
18
|
+
CREATE TABLE IF NOT EXISTS requirement_events (
|
|
19
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
network_id TEXT NOT NULL,
|
|
21
|
+
requirement_id TEXT NOT NULL,
|
|
22
|
+
seq INTEGER,
|
|
23
|
+
title TEXT,
|
|
24
|
+
actor_json TEXT,
|
|
25
|
+
kind TEXT NOT NULL,
|
|
26
|
+
field TEXT,
|
|
27
|
+
old_json TEXT,
|
|
28
|
+
new_json TEXT,
|
|
29
|
+
created_at TEXT NOT NULL
|
|
30
|
+
);
|
|
31
|
+
`);
|
|
32
|
+
database.exec("CREATE INDEX IF NOT EXISTS idx_requirement_events_network ON requirement_events(network_id, id)");
|
|
33
|
+
database.exec("CREATE INDEX IF NOT EXISTS idx_requirement_events_card ON requirement_events(requirement_id, id)");
|
|
34
|
+
database.exec("CREATE INDEX IF NOT EXISTS idx_requirement_events_created ON requirement_events(created_at)");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 流水要比较的列(requirements.ts 的 Row 的子集)。 */
|
|
38
|
+
export type EventCard = {
|
|
39
|
+
requirement_id: string;
|
|
40
|
+
network_id: string;
|
|
41
|
+
seq: number | null;
|
|
42
|
+
title: string;
|
|
43
|
+
column_name: string;
|
|
44
|
+
priority: string;
|
|
45
|
+
due_on: string | null;
|
|
46
|
+
start_on: string | null;
|
|
47
|
+
assignee: string | null;
|
|
48
|
+
owner_json: string | null;
|
|
49
|
+
agent_owner_json: string | null;
|
|
50
|
+
participants_json: string | null;
|
|
51
|
+
tags_json: string | null;
|
|
52
|
+
checklist_json: string | null;
|
|
53
|
+
description: string | null;
|
|
54
|
+
project_id: string | null;
|
|
55
|
+
parent_id: string | null;
|
|
56
|
+
archived: number | null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type EventKind = "created" | "changed" | "deleted";
|
|
60
|
+
/**
|
|
61
|
+
* field(kind = changed):column / title / priority / due / start / assignee / owner / agent_owner / participants /
|
|
62
|
+
* tags / checklist_item(勾选或取消一项:{id,text,done})/ checklist(增删改条目:{total,done})/
|
|
63
|
+
* description(只记字数,不存正文)/ project / parent / archived。
|
|
64
|
+
*/
|
|
65
|
+
export type EventDraft = { kind: EventKind; field: string | null; old: unknown; new: unknown };
|
|
66
|
+
|
|
67
|
+
const parse = (json: string | null | undefined): unknown => {
|
|
68
|
+
if (!json) return null;
|
|
69
|
+
try { return JSON.parse(json); } catch { return null; }
|
|
70
|
+
};
|
|
71
|
+
const list = (json: string | null | undefined): unknown[] => { const v = parse(json); return Array.isArray(v) ? v : []; };
|
|
72
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
|
73
|
+
type Item = { id: string; text: string; done: boolean };
|
|
74
|
+
const items = (json: string | null | undefined): Item[] =>
|
|
75
|
+
list(json).filter((x): x is Item => !!x && typeof (x as Item).id === "string" && typeof (x as Item).text === "string").map(x => ({ id: x.id, text: x.text, done: !!x.done }));
|
|
76
|
+
const counts = (xs: Item[]) => ({ total: xs.length, done: xs.filter(x => x.done).length });
|
|
77
|
+
|
|
78
|
+
/** 一次写入前后的差异。before = null:新建(只记一条 created)。 */
|
|
79
|
+
export function diffRequirement(before: EventCard | null, after: EventCard): EventDraft[] {
|
|
80
|
+
if (!before) return [{ kind: "created", field: null, old: null, new: { title: after.title, column: after.column_name } }];
|
|
81
|
+
const out: EventDraft[] = [];
|
|
82
|
+
const scalar = (field: string, a: unknown, b: unknown) => { if (!same(a, b)) out.push({ kind: "changed", field, old: a, new: b }); };
|
|
83
|
+
scalar("column", before.column_name, after.column_name);
|
|
84
|
+
scalar("title", before.title, after.title);
|
|
85
|
+
scalar("priority", before.priority, after.priority);
|
|
86
|
+
scalar("due", before.due_on || null, after.due_on || null);
|
|
87
|
+
scalar("start", before.start_on || null, after.start_on || null);
|
|
88
|
+
scalar("assignee", before.assignee || null, after.assignee || null);
|
|
89
|
+
scalar("owner", parse(before.owner_json), parse(after.owner_json));
|
|
90
|
+
scalar("agent_owner", parse(before.agent_owner_json), parse(after.agent_owner_json));
|
|
91
|
+
scalar("participants", list(before.participants_json), list(after.participants_json));
|
|
92
|
+
scalar("tags", list(before.tags_json), list(after.tags_json));
|
|
93
|
+
// 子任务:同一条(同 id、同文字)只是勾选变了 = 一条 checklist_item;条目增删 / 改字 / 换顺序 = 一条 checklist。
|
|
94
|
+
const a = items(before.checklist_json), b = items(after.checklist_json);
|
|
95
|
+
const shape = (xs: Item[]) => xs.map(x => `${x.id}\u0000${x.text}`).join("\u0001");
|
|
96
|
+
if (shape(a) === shape(b)) {
|
|
97
|
+
for (let i = 0; i < b.length; i++) if (a[i].done !== b[i].done) out.push({ kind: "changed", field: "checklist_item", old: a[i], new: b[i] });
|
|
98
|
+
} else {
|
|
99
|
+
out.push({ kind: "changed", field: "checklist", old: counts(a), new: counts(b) });
|
|
100
|
+
}
|
|
101
|
+
if ((before.description || "") !== (after.description || "")) {
|
|
102
|
+
out.push({ kind: "changed", field: "description", old: { chars: (before.description || "").length }, new: { chars: (after.description || "").length } });
|
|
103
|
+
}
|
|
104
|
+
scalar("project", before.project_id || null, after.project_id || null);
|
|
105
|
+
scalar("parent", before.parent_id || null, after.parent_id || null);
|
|
106
|
+
scalar("archived", !!before.archived, !!after.archived);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let writes = 0;
|
|
111
|
+
|
|
112
|
+
/** 记下一张卡的一组流水(调用方负责放进同一个事务)。actorJson = updated_by_json 的那个值。 */
|
|
113
|
+
export function recordRequirementEvents(database: DbAdapter, card: Pick<EventCard, "requirement_id" | "network_id" | "seq" | "title">, actorJson: string | null, drafts: readonly EventDraft[], at: string): void {
|
|
114
|
+
for (const d of drafts) {
|
|
115
|
+
database.run(
|
|
116
|
+
`INSERT INTO requirement_events (network_id, requirement_id, seq, title, actor_json, kind, field, old_json, new_json, created_at)
|
|
117
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)`,
|
|
118
|
+
[card.network_id, card.requirement_id, card.seq ?? null, card.title, actorJson && actorJson !== "null" ? actorJson : null, d.kind, d.field,
|
|
119
|
+
d.old === null || d.old === undefined ? null : JSON.stringify(d.old), d.new === null || d.new === undefined ? null : JSON.stringify(d.new), at],
|
|
120
|
+
);
|
|
121
|
+
writes++;
|
|
122
|
+
}
|
|
123
|
+
if (drafts.length && writes >= PRUNE_EVERY) {
|
|
124
|
+
writes = 0;
|
|
125
|
+
database.run("DELETE FROM requirement_events WHERE created_at < ?1", [new Date(Date.parse(at) - EVENTS_RETENTION_MS).toISOString()]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type EventRow = { id: number | string; network_id: string; requirement_id: string; seq: number | string | null; title: string | null; actor_json: string | null; kind: string; field: string | null; old_json: string | null; new_json: string | null; created_at: string };
|
|
130
|
+
|
|
131
|
+
/** 对外的一条(id 转成字符串:PostgreSQL 的 BIGSERIAL 可能回成字符串或 bigint,客户端一律按字符串比较 / 翻页)。 */
|
|
132
|
+
export const eventPublic = (row: EventRow) => ({
|
|
133
|
+
id: String(row.id),
|
|
134
|
+
requirement_id: row.requirement_id,
|
|
135
|
+
seq: row.seq == null ? null : Number(row.seq),
|
|
136
|
+
title: row.title,
|
|
137
|
+
actor: parse(row.actor_json),
|
|
138
|
+
kind: row.kind,
|
|
139
|
+
field: row.field,
|
|
140
|
+
old: parse(row.old_json),
|
|
141
|
+
new: parse(row.new_json),
|
|
142
|
+
at: row.created_at,
|
|
143
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// 参与人改了任务 → 通知相关的人(Vincent 2026-10-01「参与人可以改,但是改了之后需要有个通知」)。
|
|
2
|
+
//
|
|
3
|
+
// 谁触发:操作者是人,且是这张卡的参与人、不是负责人(权限上参与人只能改状态和检查项,见 task-access.ts
|
|
4
|
+
// PARTICIPANT_EDIT_FIELDS;老成员 task_access='all' 的参与人改了也一样通知)。只说状态和检查项的变化,别的字段不进正文。
|
|
5
|
+
// 通知谁:负责人、创建者、其他参与人 —— 只要人类、去重、不含操作者本人。
|
|
6
|
+
// 怎么送:复用人与人私信(human-dm.ts,user_inbox kind=human_dm + /events/users/me 的 desktop_message),
|
|
7
|
+
// 发信人 = 操作者。App 已有的展示:顶部提示 + 人员列表未读 + 与他的私信会话。不新增渠道。
|
|
8
|
+
// 合并:同一 (收件人, 操作者, 卡) 在 COALESCE_WINDOW_MS 内的连续改动并进同一条还没读的私信(改写正文,不再弹)。
|
|
9
|
+
|
|
10
|
+
import { db, uuidv4 } from "./db.js";
|
|
11
|
+
import { isParticipant } from "./task-access.js";
|
|
12
|
+
import { rewriteUnreadNoticeDm, sendNoticeDm } from "./human-dm.js";
|
|
13
|
+
|
|
14
|
+
export const COALESCE_WINDOW_MS = 60_000;
|
|
15
|
+
const COLUMN_LABEL: Record<string, string> = { pool: "需求池", doing: "进行中", done: "完成" };
|
|
16
|
+
const MAX_LINES = 20;
|
|
17
|
+
const MAX_ITEM_CHARS = 40;
|
|
18
|
+
|
|
19
|
+
export type NoticeCard = {
|
|
20
|
+
requirement_id: string;
|
|
21
|
+
network_id: string;
|
|
22
|
+
seq: number | null;
|
|
23
|
+
title: string;
|
|
24
|
+
column_name: string;
|
|
25
|
+
checklist_json: string | null;
|
|
26
|
+
owner_json: string | null;
|
|
27
|
+
participants_json: string | null;
|
|
28
|
+
created_by: string | null;
|
|
29
|
+
created_by_json: string | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type Item = { id: string; text: string; done: boolean };
|
|
33
|
+
const items = (raw: string | null): Item[] => {
|
|
34
|
+
try {
|
|
35
|
+
const list = raw ? JSON.parse(raw) : [];
|
|
36
|
+
return Array.isArray(list) ? list.filter((x) => x && typeof x.id === "string").map((x) => ({ id: x.id, text: String(x.text ?? ""), done: x.done === true })) : [];
|
|
37
|
+
} catch { return []; }
|
|
38
|
+
};
|
|
39
|
+
const clip = (s: string) => { const c = Array.from(s); return c.length > MAX_ITEM_CHARS ? c.slice(0, MAX_ITEM_CHARS).join("") + "…" : s; };
|
|
40
|
+
const parse = (raw: string | null) => { try { return raw ? JSON.parse(raw) : null; } catch { return null; } };
|
|
41
|
+
const userIdOf = (ref: unknown): string | null => {
|
|
42
|
+
const r = ref as { kind?: unknown; id?: unknown } | null;
|
|
43
|
+
return r && r.kind === "user" && typeof r.id === "string" ? r.id : null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 这次改动里要说的那几件事,按「对象」做键:同一个对象后一次覆盖前一次(先勾再取消 = 只剩「取消勾选」)。
|
|
48
|
+
* 键:column / ck:<id>。值:一句短话(不带主语和卡名)。
|
|
49
|
+
*/
|
|
50
|
+
export function describeChanges(before: Pick<NoticeCard, "column_name" | "checklist_json">, after: Pick<NoticeCard, "column_name" | "checklist_json">): Array<[string, string]> {
|
|
51
|
+
const out: Array<[string, string]> = [];
|
|
52
|
+
if (before.column_name !== after.column_name) out.push(["column", `改为 ${COLUMN_LABEL[after.column_name] ?? after.column_name}`]);
|
|
53
|
+
const a = items(before.checklist_json), b = items(after.checklist_json);
|
|
54
|
+
const was = new Map(a.map((x) => [x.id, x]));
|
|
55
|
+
const now = new Map(b.map((x) => [x.id, x]));
|
|
56
|
+
for (const x of b) {
|
|
57
|
+
const old = was.get(x.id);
|
|
58
|
+
if (!old) { out.push([`ck:${x.id}`, `添加了检查项「${clip(x.text)}」`]); continue; }
|
|
59
|
+
if (old.text !== x.text) out.push([`ck:${x.id}`, `把检查项「${clip(old.text)}」改为「${clip(x.text)}」`]);
|
|
60
|
+
else if (old.done !== x.done) out.push([`ck:${x.id}`, `${x.done ? "勾选了" : "取消勾选了"}检查项「${clip(x.text)}」`]);
|
|
61
|
+
}
|
|
62
|
+
for (const x of a) if (!now.has(x.id)) out.push([`ck:${x.id}`, `删除了检查项「${clip(x.text)}」`]);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 收件人:负责人、创建者、其他参与人(只要人,去重,去掉操作者)。 */
|
|
67
|
+
export function noticeRecipients(card: NoticeCard, actorUserId: string): string[] {
|
|
68
|
+
const ids = new Set<string>();
|
|
69
|
+
const owner = userIdOf(parse(card.owner_json));
|
|
70
|
+
if (owner) ids.add(owner);
|
|
71
|
+
const creator = userIdOf(parse(card.created_by_json)) ?? (!card.created_by_json && card.created_by ? card.created_by : null);
|
|
72
|
+
if (creator) ids.add(creator);
|
|
73
|
+
const participants = parse(card.participants_json);
|
|
74
|
+
if (Array.isArray(participants)) for (const p of participants) { const id = userIdOf(p); if (id) ids.add(id); }
|
|
75
|
+
ids.delete(actorUserId);
|
|
76
|
+
|
|
77
|
+
return [...ids];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function noticeText(actorName: string, title: string, lines: readonly string[]): string {
|
|
81
|
+
if (lines.length === 1) {
|
|
82
|
+
const line = lines[0];
|
|
83
|
+
return line.startsWith("改为 ") ? `${actorName} 把「${title}」${line}` : `${actorName} 在「${title}」${line}`;
|
|
84
|
+
}
|
|
85
|
+
const shown = lines.slice(-MAX_LINES);
|
|
86
|
+
const more = lines.length > shown.length ? `\n…另有 ${lines.length - shown.length} 项` : "";
|
|
87
|
+
return `${actorName} 更新了「${title}」:\n${shown.map((l) => `· ${l}`).join("\n")}${more}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type Pending = { messageId: string; startedAt: number; changes: Map<string, string> };
|
|
91
|
+
const pending = new Map<string, Pending>();
|
|
92
|
+
|
|
93
|
+
export function __resetRequirementNoticesForTest(): void { pending.clear(); }
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 参与人(非负责人)改完一张卡之后调用(事务提交之后)。before / after = 改前改后的行。
|
|
97
|
+
* 不是参与人、或者状态和检查项都没变 → 什么都不发。返回这次通知到的收件人(测试用)。
|
|
98
|
+
*/
|
|
99
|
+
export function notifyParticipantChange(input: { before: NoticeCard; after: NoticeCard; actorUserId: string; now?: number }): string[] {
|
|
100
|
+
const { before, after, actorUserId } = input;
|
|
101
|
+
if (!isParticipant(before, actorUserId)) return [];
|
|
102
|
+
if (userIdOf(parse(before.owner_json)) === actorUserId) return [];
|
|
103
|
+
|
|
104
|
+
const changes = describeChanges(before, after);
|
|
105
|
+
if (!changes.length) return [];
|
|
106
|
+
const now = input.now ?? Date.now();
|
|
107
|
+
// 用户名从库里取:MCP 入口的 auth.username 是空串。
|
|
108
|
+
const user = db.get<{ username: string; display_name: string | null }>("SELECT username, display_name FROM users WHERE user_id = ?1", actorUserId);
|
|
109
|
+
if (!user) return [];
|
|
110
|
+
const actorName = user.display_name?.trim() || user.username;
|
|
111
|
+
const sender = { userId: actorUserId, username: user.username };
|
|
112
|
+
const sent: string[] = [];
|
|
113
|
+
for (const target of noticeRecipients(after, actorUserId)) {
|
|
114
|
+
const key = `${target}\u0000${actorUserId}\u0000${after.requirement_id}`;
|
|
115
|
+
const prev = pending.get(key);
|
|
116
|
+
const live = prev && now - prev.startedAt < COALESCE_WINDOW_MS ? prev : null;
|
|
117
|
+
const merged = new Map(live?.changes ?? []);
|
|
118
|
+
for (const [k, line] of changes) { merged.delete(k); merged.set(k, line); }
|
|
119
|
+
const meta = JSON.stringify({ task_notice: { requirement_id: after.requirement_id, seq: after.seq ?? null, network_id: after.network_id } });
|
|
120
|
+
const base = { networkId: after.network_id, targetUserId: target, sender, title: "任务更新", metaJson: meta };
|
|
121
|
+
if (live && rewriteUnreadNoticeDm({ ...base, messageId: live.messageId, text: noticeText(actorName, after.title, [...merged.values()]) })) {
|
|
122
|
+
live.changes = merged;
|
|
123
|
+
sent.push(target);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const messageId = `dm_task_${uuidv4()}`;
|
|
127
|
+
const fresh = new Map(changes);
|
|
128
|
+
if (!sendNoticeDm({ ...base, messageId, text: noticeText(actorName, after.title, [...fresh.values()]) })) continue;
|
|
129
|
+
pending.set(key, { messageId, startedAt: now, changes: fresh });
|
|
130
|
+
sent.push(target);
|
|
131
|
+
}
|
|
132
|
+
if (pending.size > 5_000) for (const [k, p] of pending) if (now - p.startedAt >= COALESCE_WINDOW_MS) pending.delete(k);
|
|
133
|
+
return sent;
|
|
134
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// 参与人可以改任务的状态和检查项,改了通知相关的人(Vincent 2026-10-01)—— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
|
|
2
|
+
// test2123 在真实 PostgreSQL 上原样再跑一遍(COMMHUB_TEST_PG_URL)。
|
|
3
|
+
//
|
|
4
|
+
// 网络 NET(owner = boss)。成员:
|
|
5
|
+
// owen —— 卡的负责人;cara —— 建卡的人(task_access='all');
|
|
6
|
+
// pat —— 参与人,scoped(只看相关任务);pia —— 另一个参与人(all);
|
|
7
|
+
// sam —— scoped,与卡无关(看不见)。
|
|
8
|
+
// 正向:pat 改状态 / 勾检查项 / 改检查项列表 → 200;owen、cara、pia 各收到一条私信(发信人 pat),pat 自己没有;
|
|
9
|
+
// 60 s 内连续改动并成一条(未读时改写正文);读过之后再改 → 新的一条;MCP 与 REST 一致。
|
|
10
|
+
// 反向:pat 改标题 / 描述 / 负责人 / 参与人 / 项目 / 优先级 / 日期 / 标签 → 403 task_read_only + field,卡不变、不通知;
|
|
11
|
+
// 负责人自己改不通知;sam 照旧看不见(与不存在的卡逐字节相同)。
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-part-notify-"));
|
|
19
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
20
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
21
|
+
process.env.HOST = "127.0.0.1";
|
|
22
|
+
const PW = "PartNotifyPassw0rd!x";
|
|
23
|
+
|
|
24
|
+
type U = { token: string; id: string; username: string };
|
|
25
|
+
let BASE = "";
|
|
26
|
+
let hub: any = null;
|
|
27
|
+
let NET = "";
|
|
28
|
+
let PROJ = "";
|
|
29
|
+
let db: any;
|
|
30
|
+
let notify: typeof import("./requirement-notify.js");
|
|
31
|
+
let boss: U, owen: U, cara: U, pat: U, pia: U, sam: U;
|
|
32
|
+
|
|
33
|
+
type R = { status: number; body: any; text: string };
|
|
34
|
+
async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
|
|
35
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
36
|
+
method,
|
|
37
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
|
|
38
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
39
|
+
});
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
let body: any = null;
|
|
42
|
+
try { body = JSON.parse(text); } catch {}
|
|
43
|
+
return { status: res.status, body, text };
|
|
44
|
+
}
|
|
45
|
+
async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
|
|
46
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
49
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
50
|
+
});
|
|
51
|
+
const raw = await res.text();
|
|
52
|
+
const lines = raw.split("\n").filter(x => x.startsWith("data:"));
|
|
53
|
+
const payload = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
54
|
+
return JSON.parse(payload.result.content[0].text);
|
|
55
|
+
}
|
|
56
|
+
const userRef = (id: string) => ({ kind: "user", id });
|
|
57
|
+
|
|
58
|
+
/** 某张卡发出的通知私信:按收件人分组(只看这张卡的,meta.task_notice.requirement_id)。 */
|
|
59
|
+
function notices(reqId: string): Record<string, Array<{ message_id: string; content: string; sender_user_id: string; acked: number; from_session: string }>> {
|
|
60
|
+
const rows = db.all(
|
|
61
|
+
"SELECT message_id, user_id, sender_user_id, from_session, content, acked, meta_json FROM user_inbox WHERE network_id = ?1 AND kind = 'human_dm' ORDER BY created_at, message_id",
|
|
62
|
+
NET,
|
|
63
|
+
) as any[];
|
|
64
|
+
const out: Record<string, any[]> = {};
|
|
65
|
+
for (const r of rows) {
|
|
66
|
+
let meta: any = null;
|
|
67
|
+
try { meta = JSON.parse(r.meta_json); } catch {}
|
|
68
|
+
if (meta?.task_notice?.requirement_id !== reqId) continue;
|
|
69
|
+
(out[r.user_id] ||= []).push({ message_id: r.message_id, content: r.content, sender_user_id: r.sender_user_id, acked: Number(r.acked), from_session: r.from_session });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function card(name: string, extra: Record<string, unknown> = {}) {
|
|
75
|
+
const r = await send(cara.token, "POST", "/api/requirements", {
|
|
76
|
+
network_id: NET, name, owner: userRef(owen.id), participants: [userRef(pat.id), userRef(pia.id)],
|
|
77
|
+
checklist: [{ id: "i1", text: "写测试", done: false }, { id: "i2", text: "发版", done: false }], ...extra,
|
|
78
|
+
});
|
|
79
|
+
expect(r.status).toBe(201);
|
|
80
|
+
return r.body.requirement.id as string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
beforeAll(async () => {
|
|
84
|
+
({ db } = await import("./db.js"));
|
|
85
|
+
const { addNetworkMember, register } = await import("./auth.js");
|
|
86
|
+
const stamp = Date.now();
|
|
87
|
+
const mk = (name: string, display?: string): U => {
|
|
88
|
+
const r = register(`pn_${name}_${stamp}`, PW, undefined, display);
|
|
89
|
+
expect(r.ok).toBe(true);
|
|
90
|
+
return { token: r.token!, id: r.user!.user_id, username: r.user!.username };
|
|
91
|
+
};
|
|
92
|
+
const b = register(`pn_boss_${stamp}`, PW);
|
|
93
|
+
expect(b.ok).toBe(true);
|
|
94
|
+
boss = { token: b.token!, id: b.user!.user_id, username: b.user!.username };
|
|
95
|
+
NET = b.network_id!;
|
|
96
|
+
owen = mk("owen"); cara = mk("cara"); pat = mk("pat", "参与人甲"); pia = mk("pia"); sam = mk("sam");
|
|
97
|
+
addNetworkMember(NET, owen.id, "member", boss.id, { taskAccess: "all" });
|
|
98
|
+
addNetworkMember(NET, cara.id, "member", boss.id, { taskAccess: "all" });
|
|
99
|
+
addNetworkMember(NET, pat.id, "member", boss.id, { taskAccess: "scoped" });
|
|
100
|
+
addNetworkMember(NET, pia.id, "member", boss.id, { taskAccess: "all" });
|
|
101
|
+
addNetworkMember(NET, sam.id, "member", boss.id, { taskAccess: "scoped" });
|
|
102
|
+
notify = await import("./requirement-notify.js");
|
|
103
|
+
const mod: any = await import("./server.js");
|
|
104
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
105
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
106
|
+
// register() 给每人建了自己的网络:后面的请求一律带 network_id。
|
|
107
|
+
const p = await send(boss.token, "POST", "/api/requirements/projects", { network_id: NET, name: "pn-proj" });
|
|
108
|
+
expect(p.status).toBe(201);
|
|
109
|
+
PROJ = p.body.project.id;
|
|
110
|
+
}, 30_000);
|
|
111
|
+
|
|
112
|
+
afterAll(() => {
|
|
113
|
+
try { hub?.stop?.(true); } catch {}
|
|
114
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("参与人能改的", () => {
|
|
118
|
+
test("改状态:200,负责人 / 创建者 / 其他参与人各一条,操作者自己没有", async () => {
|
|
119
|
+
const id = await card("pn-状态");
|
|
120
|
+
const r = await send(pat.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { column: "doing" });
|
|
121
|
+
expect(r.status).toBe(200);
|
|
122
|
+
expect(r.body.requirement.column).toBe("doing");
|
|
123
|
+
const n = notices(id);
|
|
124
|
+
expect(Object.keys(n).sort()).toEqual([owen.id, cara.id, pia.id].sort());
|
|
125
|
+
for (const uid of [owen.id, cara.id, pia.id]) {
|
|
126
|
+
expect(n[uid].length).toBe(1);
|
|
127
|
+
expect(n[uid][0].sender_user_id).toBe(pat.id);
|
|
128
|
+
expect(n[uid][0].from_session).toBe(pat.username);
|
|
129
|
+
expect(n[uid][0].content).toBe("参与人甲 把「pn-状态」改为 进行中");
|
|
130
|
+
}
|
|
131
|
+
expect(n[pat.id]).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("勾检查项(单条接口):200 并通知", async () => {
|
|
135
|
+
const id = await card("pn-勾选");
|
|
136
|
+
const r = await send(pat.token, "PATCH", `/api/requirements/${id}/checklist/i1?network_id=${NET}`, { done: true });
|
|
137
|
+
expect(r.status).toBe(200);
|
|
138
|
+
expect(r.body.requirement.checklist.find((x: any) => x.id === "i1").done).toBe(true);
|
|
139
|
+
expect(notices(id)[owen.id].map(x => x.content)).toEqual(["参与人甲 在「pn-勾选」勾选了检查项「写测试」"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("整表改检查项(增 / 删 / 改字)+ 状态:一次 PATCH 一条通知,逐项列出", async () => {
|
|
143
|
+
const id = await card("pn-整表");
|
|
144
|
+
const r = await send(pat.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, {
|
|
145
|
+
column: "done", checklist: [{ id: "i1", text: "写更多测试", done: false }, { id: "i3", text: "回归", done: false }],
|
|
146
|
+
});
|
|
147
|
+
expect(r.status).toBe(200);
|
|
148
|
+
const n = notices(id)[cara.id];
|
|
149
|
+
expect(n.length).toBe(1);
|
|
150
|
+
expect(n[0].content).toBe("参与人甲 更新了「pn-整表」:\n· 改为 完成\n· 把检查项「写测试」改为「写更多测试」\n· 添加了检查项「回归」\n· 删除了检查项「发版」");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("60 s 内连续勾选并成一条(未读时改写正文);同一项先勾再取消只留最后一次", async () => {
|
|
154
|
+
const id = await card("pn-合并");
|
|
155
|
+
for (const [item, done] of [["i1", true], ["i2", true], ["i1", false]] as const) {
|
|
156
|
+
expect((await send(pat.token, "PATCH", `/api/requirements/${id}/checklist/${item}?network_id=${NET}`, { done })).status).toBe(200);
|
|
157
|
+
}
|
|
158
|
+
const n = notices(id);
|
|
159
|
+
for (const uid of [owen.id, cara.id, pia.id]) {
|
|
160
|
+
expect(n[uid].length).toBe(1);
|
|
161
|
+
expect(n[uid][0].content).toBe("参与人甲 更新了「pn-合并」:\n· 勾选了检查项「发版」\n· 取消勾选了检查项「写测试」");
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("收件人读过之后再改 → 另发一条;别人那条仍在合并", async () => {
|
|
166
|
+
const id = await card("pn-已读");
|
|
167
|
+
expect((await send(pat.token, "PATCH", `/api/requirements/${id}/checklist/i1?network_id=${NET}`, { done: true })).status).toBe(200);
|
|
168
|
+
db.run("UPDATE user_inbox SET acked = 1 WHERE user_id = ?1 AND message_id = ?2", [owen.id, notices(id)[owen.id][0].message_id]);
|
|
169
|
+
expect((await send(pat.token, "PATCH", `/api/requirements/${id}/checklist/i2?network_id=${NET}`, { done: true })).status).toBe(200);
|
|
170
|
+
const n = notices(id);
|
|
171
|
+
expect(n[owen.id].length).toBe(2);
|
|
172
|
+
expect(n[owen.id][1].content).toBe("参与人甲 在「pn-已读」勾选了检查项「发版」");
|
|
173
|
+
expect(n[cara.id].length).toBe(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("过了合并窗口 → 另发一条", async () => {
|
|
177
|
+
const id = await card("pn-窗口");
|
|
178
|
+
const row = (col: string, ck: boolean) => ({
|
|
179
|
+
requirement_id: id, network_id: NET, seq: null, title: "pn-窗口", column_name: col,
|
|
180
|
+
checklist_json: JSON.stringify([{ id: "i1", text: "写测试", done: ck }]),
|
|
181
|
+
owner_json: JSON.stringify(userRef(owen.id)), participants_json: JSON.stringify([userRef(pat.id)]),
|
|
182
|
+
created_by: null, created_by_json: JSON.stringify(userRef(cara.id)),
|
|
183
|
+
});
|
|
184
|
+
const t0 = Date.now();
|
|
185
|
+
expect(notify.notifyParticipantChange({ before: row("pool", false), after: row("pool", true), actorUserId: pat.id, now: t0 }).sort()).toEqual([owen.id, cara.id].sort());
|
|
186
|
+
notify.notifyParticipantChange({ before: row("pool", true), after: row("doing", true), actorUserId: pat.id, now: t0 + notify.COALESCE_WINDOW_MS - 1 });
|
|
187
|
+
expect(notices(id)[owen.id].length).toBe(1);
|
|
188
|
+
notify.notifyParticipantChange({ before: row("doing", true), after: row("done", true), actorUserId: pat.id, now: t0 + notify.COALESCE_WINDOW_MS + 1 });
|
|
189
|
+
expect(notices(id)[owen.id].length).toBe(2);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("推送:收件人的用户流收到 desktop_message(kind=human_dm,发信人 = 参与人)", async () => {
|
|
193
|
+
const id = await card("pn-推送");
|
|
194
|
+
const ctrl = new AbortController();
|
|
195
|
+
const res = await fetch(`${BASE}/events/users/me?network_id=${NET}`, { headers: { Authorization: `Bearer ${owen.token}` }, signal: ctrl.signal });
|
|
196
|
+
expect(res.status).toBe(200);
|
|
197
|
+
const events: any[] = [];
|
|
198
|
+
const reader = res.body!.getReader();
|
|
199
|
+
const dec = new TextDecoder();
|
|
200
|
+
let buf = "";
|
|
201
|
+
void (async () => {
|
|
202
|
+
try {
|
|
203
|
+
for (;;) {
|
|
204
|
+
const { done, value } = await reader.read();
|
|
205
|
+
if (done) break;
|
|
206
|
+
buf += dec.decode(value);
|
|
207
|
+
let i: number;
|
|
208
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
209
|
+
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
210
|
+
for (const line of frame.split("\n")) if (line.startsWith("data: ")) { try { events.push(JSON.parse(line.slice(6))); } catch {} }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch {}
|
|
214
|
+
})();
|
|
215
|
+
const until = async (cond: () => boolean) => { for (let k = 0; k < 150 && !cond(); k++) await new Promise(r => setTimeout(r, 20)); };
|
|
216
|
+
await until(() => events.some(e => e.type === "connected"));
|
|
217
|
+
expect((await send(pat.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
|
|
218
|
+
await until(() => events.some(e => e.type === "desktop_message"));
|
|
219
|
+
ctrl.abort();
|
|
220
|
+
const ev = events.find(e => e.type === "desktop_message");
|
|
221
|
+
expect(ev).toMatchObject({ kind: "human_dm", from: pat.username, from_user_id: pat.id, title: "任务更新", message: "参与人甲 把「pn-推送」改为 进行中" });
|
|
222
|
+
expect(ev.meta.task_notice.requirement_id).toBe(id);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("MCP 与 REST 一致:requirements_update 改状态能过并通知,改标题被拒", async () => {
|
|
226
|
+
const id = await card("pn-mcp");
|
|
227
|
+
const ok = await mcp(pat.token, "requirements_update", { id, network_id: NET, column: "doing" });
|
|
228
|
+
expect(ok.ok).toBe(true);
|
|
229
|
+
expect(notices(id)[owen.id][0].content).toBe("参与人甲 把「pn-mcp」改为 进行中");
|
|
230
|
+
expect(notices(id)[owen.id][0].from_session).toBe(pat.username);
|
|
231
|
+
const no = await mcp(pat.token, "requirements_update", { id, network_id: NET, name: "改名" });
|
|
232
|
+
expect(no).toMatchObject({ ok: false, error: "task_read_only", field: "name", status: 403 });
|
|
233
|
+
const tog = await mcp(pat.token, "requirements_checklist_toggle", { id, network_id: NET, item_id: "i2", done: true });
|
|
234
|
+
expect(tog.ok).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("viewer_can:参与人带 edit_fields;负责人不带", async () => {
|
|
238
|
+
const id = await card("pn-权限");
|
|
239
|
+
const mine = await send(pat.token, "GET", `/api/requirements/${id}?network_id=${NET}`);
|
|
240
|
+
expect(mine.body.requirement.viewer_can).toEqual({ edit: false, delete: false, edit_fields: ["column", "checklist"] });
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("参与人不能改的", () => {
|
|
245
|
+
test("标题 / 描述 / 负责人 / 负责 Agent / 参与人 / 项目 / 优先级 / 日期 / 标签:403 task_read_only + field;卡不变、不通知", async () => {
|
|
246
|
+
const id = await card("pn-拒绝");
|
|
247
|
+
const before = (await send(cara.token, "GET", `/api/requirements/${id}?network_id=${NET}`)).body.requirement;
|
|
248
|
+
const cases: Array<[string, unknown]> = [
|
|
249
|
+
["name", "改名"], ["description", "x"], ["owner", userRef(pat.id)], ["agent_owner", null], ["participants", [userRef(pat.id)]],
|
|
250
|
+
["project_id", PROJ], ["priority", "high"], ["due", "2026-12-31"], ["start", "2026-12-01"], ["tags", ["t"]], ["archived", true], ["parent_id", null],
|
|
251
|
+
];
|
|
252
|
+
for (const [field, value] of cases) {
|
|
253
|
+
const r = await send(pat.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { [field]: value });
|
|
254
|
+
expect(r.status).toBe(403);
|
|
255
|
+
expect(r.body.ok).toBe(false);
|
|
256
|
+
expect(r.body.error).toBe("task_read_only");
|
|
257
|
+
expect(r.body.field).toBe(field);
|
|
258
|
+
expect(typeof r.body.message).toBe("string");
|
|
259
|
+
}
|
|
260
|
+
// 混着能改的字段一起发:整条拒绝,状态也不动。
|
|
261
|
+
const mixed = await send(pat.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { column: "doing", name: "改名" });
|
|
262
|
+
expect(mixed.status).toBe(403);
|
|
263
|
+
expect(mixed.body.field).toBe("name");
|
|
264
|
+
const after = (await send(cara.token, "GET", `/api/requirements/${id}?network_id=${NET}`)).body.requirement;
|
|
265
|
+
expect(after).toEqual(before);
|
|
266
|
+
expect(notices(id)).toEqual({});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("负责人 / 创建者自己改:不通知;删除仍不行", async () => {
|
|
270
|
+
const id = await card("pn-负责人");
|
|
271
|
+
expect((await send(owen.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
|
|
272
|
+
expect((await send(cara.token, "PATCH", `/api/requirements/${id}/checklist/i1?network_id=${NET}`, { done: true })).status).toBe(200);
|
|
273
|
+
expect(notices(id)).toEqual({});
|
|
274
|
+
expect((await send(pat.token, "DELETE", `/api/requirements/${id}?network_id=${NET}`)).status).toBe(403);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("负责人同时是参与人:改了也不通知", async () => {
|
|
278
|
+
const id = await card("pn-兼任", { owner: userRef(pat.id), participants: [userRef(pat.id), userRef(pia.id)] });
|
|
279
|
+
expect((await send(pat.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
|
|
280
|
+
expect(notices(id)).toEqual({});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("只看相关任务的无关成员:看不见、改不了(与不存在的卡逐字节相同),也收不到通知", async () => {
|
|
284
|
+
const id = await card("pn-无关");
|
|
285
|
+
const list = await send(sam.token, "GET", `/api/requirements?network_id=${NET}`);
|
|
286
|
+
expect((list.body.requirements as any[]).some(x => x.id === id)).toBe(false);
|
|
287
|
+
const hidden = await send(sam.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { column: "doing" });
|
|
288
|
+
const ghost = await send(sam.token, "PATCH", `/api/requirements/req_does_not_exist?network_id=${NET}`, { column: "doing" });
|
|
289
|
+
expect(hidden.status).toBe(404);
|
|
290
|
+
expect(hidden.text).toBe(ghost.text);
|
|
291
|
+
const hiddenCk = await send(sam.token, "PATCH", `/api/requirements/${id}/checklist/i1?network_id=${NET}`, { done: true });
|
|
292
|
+
expect(hiddenCk.status).toBe(404);
|
|
293
|
+
expect((await send(pat.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
|
|
294
|
+
expect(notices(id)[sam.id]).toBeUndefined();
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("收件人(纯函数)", () => {
|
|
299
|
+
test("只要人、去重、去掉操作者;旧卡没有 created_by_json 时用 created_by", () => {
|
|
300
|
+
const base = {
|
|
301
|
+
requirement_id: "r", network_id: NET, seq: 1, title: "t", column_name: "pool", checklist_json: null,
|
|
302
|
+
owner_json: JSON.stringify(userRef("u-owner")),
|
|
303
|
+
participants_json: JSON.stringify([userRef("u-actor"), userRef("u-owner"), { kind: "node", id: "n-1" }, userRef("u-other")]),
|
|
304
|
+
created_by: null, created_by_json: JSON.stringify({ kind: "node", id: "n-2" }),
|
|
305
|
+
};
|
|
306
|
+
expect(notify.noticeRecipients(base, "u-actor").sort()).toEqual(["u-other", "u-owner"]);
|
|
307
|
+
expect(notify.noticeRecipients({ ...base, created_by_json: null, created_by: "u-legacy" }, "u-actor").sort()).toEqual(["u-legacy", "u-other", "u-owner"]);
|
|
308
|
+
});
|
|
309
|
+
});
|