@sleep2agi/commhub-server 0.9.0-preview.79 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.79",
3
+ "version": "0.9.0-preview.80",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/human-dm.ts CHANGED
@@ -78,13 +78,7 @@ export function sendHumanDm(input: HumanDmSendInput): HumanDmSendResult {
78
78
  const metaJson = attachments.attachments.length ? JSON.stringify({ attachments: attachments.attachments }) : null;
79
79
 
80
80
  db.transaction(() => {
81
- db.run(
82
- // created_at 带毫秒:同一秒里一来一回的两条私信要能排出先后(秒级的 datetime('now') 排不出)。
83
- `INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, sender_user_id, created_at)
84
- VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, 'info', ?7, ?8, strftime('%Y-%m-%d %H:%M:%f', 'now'))
85
- ON CONFLICT(message_id) DO NOTHING`,
86
- [messageId, networkId, target.user_id, sender.username, HUMAN_DM_KIND, text, metaJson, sender.userId],
87
- );
81
+ insertDmRow({ messageId, networkId, targetUserId: target.user_id, sender, title: null, text, metaJson });
88
82
  db.run(
89
83
  `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
90
84
  VALUES (?1, ?2, 'human_dm_sent', 'user', ?3, ?4, ?5)`,
@@ -97,20 +91,65 @@ export function sendHumanDm(input: HumanDmSendInput): HumanDmSendResult {
97
91
  messageId,
98
92
  )!;
99
93
  // 推送在提交之后(与 send_desktop_message 同一条规矩:回滚的消息不能先被看见)。
100
- const delivered = hasUserSubscribers(networkId, target.user_id);
101
- pushUserEvent(networkId, target.user_id, {
94
+ const delivered = pushDmEvent({ messageId, networkId, targetUserId: target.user_id, sender, title: null, text, metaJson });
95
+ return { ok: true, message: redactMessageRow(row), delivered };
96
+ }
97
+
98
+ type DmRow = { messageId: string; networkId: string; targetUserId: string; sender: { userId: string; username: string }; title: string | null; text: string; metaJson: string | null };
99
+
100
+ function insertDmRow(m: DmRow): void {
101
+ db.run(
102
+ // created_at 带毫秒:同一秒里一来一回的两条私信要能排出先后(秒级的 datetime('now') 排不出)。
103
+ `INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, sender_user_id, created_at)
104
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'info', ?8, ?9, strftime('%Y-%m-%d %H:%M:%f', 'now'))
105
+ ON CONFLICT(message_id) DO NOTHING`,
106
+ [m.messageId, m.networkId, m.targetUserId, m.sender.username, HUMAN_DM_KIND, m.title, m.text, m.metaJson, m.sender.userId],
107
+ );
108
+ }
109
+
110
+ /** 推 desktop_message 到收件人的 /events/users/me。返回推送时他有没有在线的订阅。 */
111
+ function pushDmEvent(m: DmRow): boolean {
112
+ const delivered = hasUserSubscribers(m.networkId, m.targetUserId);
113
+ pushUserEvent(m.networkId, m.targetUserId, {
102
114
  type: "desktop_message",
103
- message_id: messageId,
115
+ message_id: m.messageId,
104
116
  kind: HUMAN_DM_KIND,
105
- from: sender.username,
106
- from_user_id: sender.userId,
107
- title: null,
108
- message: text,
117
+ from: m.sender.username,
118
+ from_user_id: m.sender.userId,
119
+ title: m.title,
120
+ message: m.text,
109
121
  severity: "info",
110
122
  created_at: new Date().toISOString(),
111
- ...(metaJson ? { meta: JSON.parse(metaJson) } : {}),
123
+ ...(m.metaJson ? { meta: JSON.parse(m.metaJson) } : {}),
112
124
  });
113
- return { ok: true, message: redactMessageRow(row), delivered };
125
+ return delivered;
126
+ }
127
+
128
+ /**
129
+ * Hub 代某人发的一条私信(参与人改了任务的通知,见 requirement-notify.ts):与人发的私信同一张表、同一条推送,
130
+ * 所以 App 按私信展示(顶部提示 + 人员列表未读 + 与他的会话里能看到),不用新渠道。
131
+ * 不校验正文 / 附件(调用方生成),也不记 human_dm_sent 审计(这不是他亲手发的)。
132
+ * 收件人或发信人已不在网络里 → 不发。返回 false = 没发。
133
+ */
134
+ export function sendNoticeDm(m: DmRow): boolean {
135
+ if (m.targetUserId === m.sender.userId) return false;
136
+ if (!getUserNetworkRole(m.sender.userId, m.networkId) || !getUserNetworkRole(m.targetUserId, m.networkId)) return false;
137
+ insertDmRow(m);
138
+ pushDmEvent(m);
139
+ return true;
140
+ }
141
+
142
+ /**
143
+ * 把一条还没读的通知私信改写成合并后的正文(同一段时间里的连续改动并成一条)。
144
+ * 收件人已经读过(acked)或那行没了 → false,调用方另发一条新的。
145
+ * 只改库、再推一次同一个 message_id:App 按 message_id 去重,不会第二次弹;会话列表下次拉到的是新正文。
146
+ */
147
+ export function rewriteUnreadNoticeDm(m: DmRow): boolean {
148
+ const row = db.get<{ acked: number | boolean | null }>("SELECT acked FROM user_inbox WHERE message_id = ?1 AND user_id = ?2", m.messageId, m.targetUserId);
149
+ if (!row || row.acked) return false;
150
+ db.run("UPDATE user_inbox SET content = ?1, meta_json = ?2 WHERE message_id = ?3 AND user_id = ?4", [m.text, m.metaJson, m.messageId, m.targetUserId]);
151
+ pushDmEvent(m);
152
+ return true;
114
153
  }
115
154
 
116
155
  /** 带附件:文件必须属于这个网络(防把别的网络的 file_id 塞进来)。返回它的索引条目,不属于 → null。 */
@@ -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
+ });
@@ -9,9 +9,10 @@ import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-s
9
9
  import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
10
10
  import { isAgentRestricted, restrictedNetworkIds, visibleAgents } from "./agent-access.js";
11
11
  import {
12
- addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject,
13
- isTaskScoped, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
12
+ addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, canParticipantEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject,
13
+ isTaskScoped, PARTICIPANT_EDIT_FIELDS, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
14
14
  } from "./task-access.js";
15
+ import { notifyParticipantChange } from "./requirement-notify.js";
15
16
  import { diffRequirement, ensureRequirementEvents, eventPublic, recordRequirementEvents, type EventRow } from "./requirement-events.js";
16
17
  import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
17
18
 
@@ -87,12 +88,31 @@ function taskCaller(ctx: RequirementsRequestContext): TaskCaller {
87
88
  if (!ctx.auth || ctx.isNodeToken || ctx.isAdmin) return null;
88
89
  return { userId: ctx.auth.userId };
89
90
  }
90
- /** 看得见但没权限写:403,并按 (用户, 卡) 每小时最多记一条审计。 */
91
- function taskDenied(ctx: RequirementsRequestContext, row: Row, error: "task_read_only" | "task_delete_denied"): Response {
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 {
92
96
  if (ctx.auth && shouldAuditDenied(ctx.auth.userId, row.requirement_id)) {
93
- logAudit(ctx.auth.userId, ctx.auth.username || null, "task_access_denied", "requirement", row.requirement_id, JSON.stringify({ error }), undefined, row.network_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}`);
94
115
  }
95
- return jsonError(error, 403);
96
116
  }
97
117
  /** 父卡对调用者不可见 = 与不存在同一个错误(不给 scoped 成员留卡片存在性探测)。 */
98
118
  function parentHidden(ctx: RequirementsRequestContext, parentId: string): boolean {
@@ -785,7 +805,12 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
785
805
  const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
786
806
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
787
807
  const caller = taskCaller(ctx);
788
- if (!canEditTask(caller, row)) return taskDenied(ctx, row, "task_read_only");
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
+ }
789
814
  // scoped 成员不能写 external_ref:唯一索引冲突会泄露别的卡用了这个 ref。
790
815
  if (has("external_ref") && caller && isTaskScoped(caller.userId, row.network_id)) return jsonError("external_ref_not_allowed", 403);
791
816
  if (has("column") && !COLUMNS.has(String(body.column))) return jsonError("invalid_column", 400);
@@ -887,6 +912,7 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
887
912
  recordRequirementEvents(db, after, actor, diffRequirement(row, after), updatedAt);
888
913
  return after;
889
914
  });
915
+ notifyIfParticipant(ctx, row, updated);
890
916
  return Response.json(withLegacyOwner(toPublicFor(ctx, updated), people.coerced));
891
917
  }
892
918
 
@@ -1357,7 +1383,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1357
1383
  const row = scopedRow(ctx, decodeURIComponent(itemMatch[1]));
1358
1384
  if (row instanceof Response) return row;
1359
1385
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
1360
- if (!canEditTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_read_only");
1386
+ if (!canEditTask(taskCaller(ctx), row) && !canParticipantEditTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_read_only");
1361
1387
  // 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
1362
1388
  const items = storedChecklist(row.checklist_json);
1363
1389
  const itemId = decodeURIComponent(itemMatch[2]);
@@ -1373,6 +1399,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1373
1399
  if (was !== item.done) recordRequirementEvents(db, row, actor, [{ kind: "changed", field: "checklist_item", old: { ...item, done: was }, new: { ...item } }], at);
1374
1400
  return db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
1375
1401
  });
1402
+ notifyIfParticipant(ctx, row, updated);
1376
1403
  return Response.json({ ok: true, requirement: toPublicFor(ctx, updated) });
1377
1404
  }
1378
1405
 
@@ -192,7 +192,8 @@ describe("可见性", () => {
192
192
  const r = await get(alice.token, `/api/requirements?network_id=${NET}`);
193
193
  const by = (n: string) => (r.body.requirements as any[]).find(x => x.name === n)?.viewer_can;
194
194
  expect(by("ta-own")).toEqual({ edit: true, delete: true });
195
- expect(by("ta-part")).toEqual({ edit: false, delete: false });
195
+ // 参与人:整卡只读,但状态和检查项能改(edit_fields;旧 App 不认这个键,照旧画只读)。
196
+ expect(by("ta-part")).toEqual({ edit: false, delete: false, edit_fields: ["column", "checklist"] });
196
197
  expect(by("ta-p1")).toEqual({ edit: false, delete: false });
197
198
  expect(by("ta-p2")).toEqual({ edit: true, delete: false });
198
199
  expect((await get(vic.token, `/api/requirements/${C["ta-p3"].id}?network_id=${NET}`)).body.requirement.viewer_can).toEqual({ edit: false, delete: false });
@@ -235,25 +236,28 @@ describe("可见性", () => {
235
236
  });
236
237
 
237
238
  describe("写入", () => {
238
- test("能改:负责的、可改项目里的;只读:参与的、只看项目里的(403 task_read_only);看不见的与不存在的同一个 404", async () => {
239
+ test("能改:负责的、可改项目里的;只读:只看项目里的(403 task_read_only);参与的只能改状态 / 检查项;看不见的与不存在的同一个 404", async () => {
239
240
  expect((await send(alice.token, "PATCH", `/api/requirements/${C["ta-own"].id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
240
241
  expect((await send(alice.token, "PATCH", `/api/requirements/${C["ta-p2"].id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
241
- for (const n of ["ta-part", "ta-p1"]) {
242
- const r = await send(alice.token, "PATCH", `/api/requirements/${C[n].id}?network_id=${NET}`, { column: "doing" });
243
- expect(r.status).toBe(403);
244
- expect(r.body.error).toBe("task_read_only");
245
- }
242
+ const p1 = await send(alice.token, "PATCH", `/api/requirements/${C["ta-p1"].id}?network_id=${NET}`, { column: "doing" });
243
+ expect(p1.status).toBe(403);
244
+ expect(p1.body).toEqual({ ok: false, error: "task_read_only" });
245
+ const part = await send(alice.token, "PATCH", `/api/requirements/${C["ta-part"].id}?network_id=${NET}`, { name: "renamed" });
246
+ expect(part.status).toBe(403);
247
+ expect(part.body.error).toBe("task_read_only");
248
+ expect(part.body.field).toBe("name");
246
249
  const hidden = await send(alice.token, "PATCH", `/api/requirements/${C["ta-other"].id}?network_id=${NET}`, { column: "doing" });
247
250
  const ghost = await send(alice.token, "PATCH", `/api/requirements/req_does_not_exist?network_id=${NET}`, { column: "doing" });
248
251
  expect(hidden.status).toBe(404);
249
252
  expect(hidden.text).toBe(ghost.text);
250
253
  });
251
254
 
252
- test("勾子任务:负责的能勾,参与的 403,看不见的 404", async () => {
255
+ test("勾子任务:负责的能勾,参与的能勾,只看项目的 403,看不见的 404", async () => {
253
256
  expect((await send(alice.token, "PATCH", `/api/requirements/${C["ta-own"].id}/checklist/i1?network_id=${NET}`, { done: true })).status).toBe(200);
254
- const part = await send(alice.token, "PATCH", `/api/requirements/${C["ta-part"].id}/checklist/i1?network_id=${NET}`, { done: true });
255
- expect(part.status).toBe(403);
256
- expect(part.body.error).toBe("task_read_only");
257
+ expect((await send(alice.token, "PATCH", `/api/requirements/${C["ta-part"].id}/checklist/i1?network_id=${NET}`, { done: true })).status).toBe(200);
258
+ const ro = await send(alice.token, "PATCH", `/api/requirements/${C["ta-p1"].id}/checklist/i1?network_id=${NET}`, { done: true });
259
+ expect(ro.status).toBe(403);
260
+ expect(ro.body.error).toBe("task_read_only");
257
261
  expect((await send(alice.token, "PATCH", `/api/requirements/${C["ta-other"].id}/checklist/i1?network_id=${NET}`, { done: true })).status).toBe(404);
258
262
  });
259
263
 
@@ -133,6 +133,26 @@ export function canEditTask(caller: TaskCaller, row: CardRow): boolean {
133
133
  return projectGrant(row.network_id, caller.userId, row.project_id)?.can_edit === true;
134
134
  }
135
135
 
136
+ /**
137
+ * 参与人能改的字段(Vincent 2026-10-01「参与人可以改,但是改了之后需要有个通知」):状态(列)和检查项。
138
+ * 只对 canEditTask 为 false 的 scoped 参与人生效;标题 / 描述 / 负责人 / 参与人 / 项目 / 优先级 / 日期 / 标签等仍只给
139
+ * 负责人 / 创建者 / 可改项目授权。
140
+ */
141
+ export const PARTICIPANT_EDIT_FIELDS: readonly string[] = ["column", "checklist"];
142
+
143
+ /** 这个调用者是不是「只能以参与人身份改」这张卡(canEditTask 为 false、但他是卡上的参与人)。viewer 不算。 */
144
+ export function canParticipantEditTask(caller: TaskCaller, row: CardRow): boolean {
145
+ if (!caller) return false;
146
+ const m = memberRow(caller.userId, row.network_id);
147
+ if (!scopedRow(m) || m?.member_role === "viewer") return false;
148
+ return participates(row, caller.userId);
149
+ }
150
+
151
+ /** 卡上有没有这个用户当参与人(不看权限模式;通知用)。 */
152
+ export function isParticipant(row: Pick<CardRow, "participants_json">, userId: string): boolean {
153
+ return refIs(row.participants_json, userId);
154
+ }
155
+
136
156
  /** 能删这张卡吗。scoped 成员只能删负责人是自己或自己建的。 */
137
157
  export function canDeleteTask(caller: TaskCaller, row: CardRow): boolean {
138
158
  if (!caller) return true;
@@ -146,7 +166,8 @@ export function canDeleteTask(caller: TaskCaller, row: CardRow): boolean {
146
166
  * 给客户端画「只读 / 不能删」用的每卡权限。只对 scoped 调用者返回(其余 null = 与今天一样全能)。
147
167
  * 一个请求里按网络缓存成员行与项目授权,列表 500 张卡不会变成 500 × 3 次查询。
148
168
  */
149
- export type TaskPermissions = { edit: boolean; delete: boolean };
169
+ // edit_fields:只在 edit=false、但调用者是这张卡的参与人时出现 —— 他能改的就是这几个字段(旧 App 不认这个键,照旧画只读)。
170
+ export type TaskPermissions = { edit: boolean; delete: boolean; edit_fields?: readonly string[] };
150
171
  export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => TaskPermissions | null {
151
172
  if (!caller) return () => null;
152
173
  const cache = new Map<string, { scoped: boolean; viewer: boolean; grants: Map<string, boolean> }>();
@@ -165,7 +186,9 @@ export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => T
165
186
  if (!c.scoped) return null;
166
187
  if (c.viewer) return { edit: false, delete: false };
167
188
  const mine = ownsCard(row, caller.userId) || createdCard(row, caller.userId);
168
- return { edit: mine || (row.project_id ? c.grants.get(row.project_id) === true : false), delete: mine };
189
+ const edit = mine || (row.project_id ? c.grants.get(row.project_id) === true : false);
190
+ if (!edit && participates(row, caller.userId)) return { edit, delete: mine, edit_fields: PARTICIPANT_EDIT_FIELDS };
191
+ return { edit, delete: mine };
169
192
  };
170
193
  }
171
194