@sleep2agi/commhub-server 0.9.0-preview.92 → 0.9.0-preview.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auth.ts +3 -3
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/departments.ts +31 -10
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/human-dm.ts +2 -2
- package/src/mcp-context-cost-http.test.ts +4 -2
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-permissions-http.test.ts +18 -0
- package/src/requirement-due-reminders-http.test.ts +390 -0
- package/src/requirement-due-reminders.ts +310 -0
- package/src/requirements-errors.ts +2 -0
- package/src/requirements-status-participants-http.test.ts +5 -1
- package/src/requirements.ts +16 -1
- package/src/server.ts +171 -10
- package/src/tool-audience-http.test.ts +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +2 -1
- package/src/tools.ts +36 -6
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// 部门群(RFC-042,看板 #457,父任务 #419)—— 第一个 PR:表结构 + 建群 / 查群 + 权限;第二个 PR:成员同步 + 手动成员 + 改群名。
|
|
2
|
+
//
|
|
3
|
+
// Hub 今天没有任何「人类群聊」:人与人只有一对一私信(human-dm.ts,走 user_inbox),agent_groups 是 Agent 授权分组,
|
|
4
|
+
// 不是聊天。所以部门群先落一个通用的群(chat_groups),再用一个可空的 department_id 把它挂到部门上。
|
|
5
|
+
//
|
|
6
|
+
// 只增迁移:两张新表,零改列。旧代码不读它们,回滚安全。
|
|
7
|
+
//
|
|
8
|
+
// 规则(本 PR):
|
|
9
|
+
// - 部门群按需建(opt-in):网络 owner / admin、Hub 管理员,或这个部门(含上级)的负责人。一个部门最多一个群。
|
|
10
|
+
// - 建群时成员 = 部门子树里的成员 ∪ 子树里各部门的负责人(source='department')。之后的自动加入 / 退出是下一个 PR。
|
|
11
|
+
// - Agent(节点令牌)不进群、也不能读写群。
|
|
12
|
+
// - 删部门 = 解除关联(department_id 置空),群和成员都留着;删网络 = 群一起删。
|
|
13
|
+
// - 读:群成员,和网络 owner / admin / Hub 管理员(管理用),和部门(含上级)负责人。别人 → 404(不暴露群是否存在)。
|
|
14
|
+
//
|
|
15
|
+
// 第二个 PR(RFC-042 §4):
|
|
16
|
+
// - syncDepartmentGroups():roster 有、群里没有 → 插 source='department';群里 source='department'、roster 没了 → 删;
|
|
17
|
+
// source='manual' 永远不动(手动拉的人后来进了部门也保持 manual —— 宁可多留一个人,不静默踢人)。
|
|
18
|
+
// 调人 / 建部门(带负责人)/ 改上级 / 换负责人 / 删部门 都在各自的事务里调它一次;移出网络删他在本网络的全部群成员行。
|
|
19
|
+
// - 读一个挂部门的群时顺手对账一次(兜底:修掉第 1 个 PR 上线到第 2 个 PR 上线之间的漂移)。
|
|
20
|
+
//
|
|
21
|
+
// RFC-042 §9.3 补口:入群 / 退群实时事件 group_membership_changed。成员行在事务里变,事件只在**最外层事务提交之后**推:
|
|
22
|
+
// 写路径一律走 groupTx()(代替 db.transaction),事务里每一处增删成员行都 recordMembershipChange() 记一笔;
|
|
23
|
+
// groupTx 正常返回且已回到最外层 → flush;抛错(整个回滚,或内层 savepoint 回滚)→ 丢掉这一层记下的那些。
|
|
24
|
+
|
|
25
|
+
import { db, uuidv4 } from "./db.js";
|
|
26
|
+
import { departmentSubtree, membersIn } from "./department-heads.js";
|
|
27
|
+
import { pushUserEvent } from "./push.js";
|
|
28
|
+
|
|
29
|
+
db.exec(`
|
|
30
|
+
CREATE TABLE IF NOT EXISTS chat_groups (
|
|
31
|
+
group_id TEXT PRIMARY KEY,
|
|
32
|
+
network_id TEXT NOT NULL,
|
|
33
|
+
name TEXT NOT NULL,
|
|
34
|
+
department_id TEXT,
|
|
35
|
+
created_by TEXT,
|
|
36
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
37
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
38
|
+
);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_chat_groups_network ON chat_groups(network_id);
|
|
40
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_groups_department ON chat_groups(network_id, department_id);
|
|
41
|
+
CREATE TABLE IF NOT EXISTS chat_group_members (
|
|
42
|
+
group_id TEXT NOT NULL,
|
|
43
|
+
user_id TEXT NOT NULL,
|
|
44
|
+
network_id TEXT NOT NULL,
|
|
45
|
+
source TEXT NOT NULL DEFAULT 'manual',
|
|
46
|
+
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
47
|
+
PRIMARY KEY (group_id, user_id)
|
|
48
|
+
);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_chat_group_members_user ON chat_group_members(network_id, user_id);
|
|
50
|
+
-- 第三个 PR(群消息,group-messages.ts):一条消息一行,每人已读位置一行。只增。
|
|
51
|
+
CREATE TABLE IF NOT EXISTS chat_group_messages (
|
|
52
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
53
|
+
message_id TEXT NOT NULL UNIQUE,
|
|
54
|
+
group_id TEXT NOT NULL,
|
|
55
|
+
network_id TEXT NOT NULL,
|
|
56
|
+
sender_user_id TEXT NOT NULL,
|
|
57
|
+
from_session TEXT NOT NULL,
|
|
58
|
+
content TEXT NOT NULL DEFAULT '',
|
|
59
|
+
meta_json TEXT,
|
|
60
|
+
created_at TEXT NOT NULL
|
|
61
|
+
);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_chat_group_messages_group ON chat_group_messages(group_id, seq);
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_chat_group_messages_network ON chat_group_messages(network_id);
|
|
64
|
+
CREATE TABLE IF NOT EXISTS chat_group_reads (
|
|
65
|
+
group_id TEXT NOT NULL,
|
|
66
|
+
user_id TEXT NOT NULL,
|
|
67
|
+
network_id TEXT NOT NULL,
|
|
68
|
+
last_read_seq INTEGER NOT NULL DEFAULT 0,
|
|
69
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
70
|
+
PRIMARY KEY (group_id, user_id)
|
|
71
|
+
);
|
|
72
|
+
`);
|
|
73
|
+
// 唯一索引 (network_id, department_id):SQLite 与 PostgreSQL 都允许多行 NULL,所以解除关联的群(department_id 为空)不受限,
|
|
74
|
+
// 而同一个部门只能挂一个群。
|
|
75
|
+
|
|
76
|
+
export const GROUP_SOURCE_DEPARTMENT = "department";
|
|
77
|
+
export const GROUP_SOURCE_MANUAL = "manual";
|
|
78
|
+
|
|
79
|
+
// ── 入群 / 退群事件(RFC-042 §9.3 补口)──
|
|
80
|
+
|
|
81
|
+
export const GROUP_MEMBERSHIP_EVENT = "group_membership_changed";
|
|
82
|
+
/** 事件里的 source = 这次变化的起因:manual(手动拉 / 移)、department(部门同步,含建群播种)、network_removal(移出网络)。 */
|
|
83
|
+
export type MembershipChangeSource = "manual" | "department" | "network_removal";
|
|
84
|
+
export type MembershipChange = { network_id: string; group_id: string; user_id: string; change: "added" | "removed"; source: MembershipChangeSource; at: string };
|
|
85
|
+
|
|
86
|
+
let txDepth = 0;
|
|
87
|
+
let pending: MembershipChange[] = [];
|
|
88
|
+
|
|
89
|
+
/** 事务里记一笔成员变化。不在 groupTx 里(单条自动提交的写)→ 这条语句已经提交了,立刻推。 */
|
|
90
|
+
function recordMembershipChange(c: Omit<MembershipChange, "at">): void {
|
|
91
|
+
const change = { ...c, at: new Date().toISOString() };
|
|
92
|
+
if (txDepth > 0) pending.push(change);
|
|
93
|
+
else flushMembershipChanges([change]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 代替 db.transaction:可嵌套(内层是 savepoint)。内层抛错 → 只丢掉内层记下的变化(内层 savepoint 回滚了,外层可能照常提交);
|
|
98
|
+
* 最外层正常返回(= 已提交)→ 推;最外层抛错(= 回滚)→ 一条不推。
|
|
99
|
+
*/
|
|
100
|
+
export function groupTx<T>(fn: () => T): T {
|
|
101
|
+
const mark = pending.length;
|
|
102
|
+
txDepth++;
|
|
103
|
+
let ok = false;
|
|
104
|
+
try {
|
|
105
|
+
const out = db.transaction(fn);
|
|
106
|
+
ok = true;
|
|
107
|
+
return out;
|
|
108
|
+
} finally {
|
|
109
|
+
txDepth--;
|
|
110
|
+
if (!ok) pending.length = mark;
|
|
111
|
+
else if (txDepth === 0) {
|
|
112
|
+
const batch = pending;
|
|
113
|
+
pending = [];
|
|
114
|
+
flushMembershipChanges(batch);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 推 group_membership_changed:每一笔发给「被加 / 被移的那个人」和「提交后这个群的当前成员」。
|
|
121
|
+
* pushUserEvent 的信封里 user_id 是**收件人**(所有用户事件同一口径),所以被加 / 被移的人放在 member_user_id。
|
|
122
|
+
*/
|
|
123
|
+
function flushMembershipChanges(batch: MembershipChange[]): void {
|
|
124
|
+
if (!batch.length) return;
|
|
125
|
+
const membersOf = new Map<string, string[]>();
|
|
126
|
+
for (const c of batch) {
|
|
127
|
+
let current = membersOf.get(c.group_id);
|
|
128
|
+
if (!current) {
|
|
129
|
+
current = db.all<{ user_id: string }>("SELECT user_id FROM chat_group_members WHERE group_id = ?1", c.group_id).map((r) => r.user_id);
|
|
130
|
+
membersOf.set(c.group_id, current);
|
|
131
|
+
}
|
|
132
|
+
const event = { type: GROUP_MEMBERSHIP_EVENT, group_id: c.group_id, member_user_id: c.user_id, change: c.change, source: c.source, at: c.at };
|
|
133
|
+
for (const uid of new Set([c.user_id, ...current])) {
|
|
134
|
+
try { pushUserEvent(c.network_id, uid, event); } catch {}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const NAME_MAX = 40;
|
|
139
|
+
|
|
140
|
+
type GroupRow = { group_id: string; network_id: string; name: string; department_id: string | null; created_by: string | null; created_at: string; updated_at: string };
|
|
141
|
+
export type ChatGroup = {
|
|
142
|
+
id: string;
|
|
143
|
+
network_id: string;
|
|
144
|
+
name: string;
|
|
145
|
+
department_id: string | null;
|
|
146
|
+
member_count: number;
|
|
147
|
+
created_by: string | null;
|
|
148
|
+
created_at: string;
|
|
149
|
+
updated_at: string;
|
|
150
|
+
};
|
|
151
|
+
// username / display_name(App 补口,只增):display_name 与 people 接口同一口径 —— 没设或等于 username → ""。
|
|
152
|
+
export type ChatGroupMember = { user_id: string; source: string; joined_at: string; username: string; display_name: string };
|
|
153
|
+
/** 群接口上「我能做什么」(App 补口,只增)。和服务端真正放行的判据是同一个:manage = canManageGroup(改名 / 拉人 / 移人),
|
|
154
|
+
* post = 当前是群成员(group-messages.ts memberGroup:发消息 / 读历史 / 标已读)。 */
|
|
155
|
+
export type GroupViewerCan = { manage: boolean; post: boolean };
|
|
156
|
+
|
|
157
|
+
type Fail = { ok: false; status: number; error: string };
|
|
158
|
+
const fail = (status: number, error: string): Fail => ({ ok: false, status, error });
|
|
159
|
+
|
|
160
|
+
const GROUP_COLS = "group_id, network_id, name, department_id, created_by, created_at, updated_at";
|
|
161
|
+
|
|
162
|
+
function groupRow(networkId: string, groupId: string): GroupRow | null {
|
|
163
|
+
return db.get<GroupRow>(`SELECT ${GROUP_COLS} FROM chat_groups WHERE network_id = ?1 AND group_id = ?2`, networkId, groupId) ?? null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function memberCount(groupId: string): number {
|
|
167
|
+
return Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM chat_group_members WHERE group_id = ?1", groupId)?.n ?? 0);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function toPublic(r: GroupRow): ChatGroup {
|
|
171
|
+
return { id: r.group_id, network_id: r.network_id, name: r.name, department_id: r.department_id, member_count: memberCount(r.group_id), created_by: r.created_by, created_at: r.created_at, updated_at: r.updated_at };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function isGroupMember(groupId: string, userId: string): boolean {
|
|
175
|
+
return !!db.get("SELECT 1 AS x FROM chat_group_members WHERE group_id = ?1 AND user_id = ?2", groupId, userId);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 部门群「应有」的成员:部门子树里的成员 ∪ 子树里各部门的负责人(负责人得是网络成员、不是 viewer 也算 —— 进群只是能聊天,
|
|
180
|
+
* 不是管理权)。部门不存在 → 空集。成员同步(下一个 PR)也用这一个函数,建群和同步按同一个口径。
|
|
181
|
+
*/
|
|
182
|
+
export function departmentGroupRoster(networkId: string, departmentId: string): Set<string> {
|
|
183
|
+
const subtree = departmentSubtree(networkId, departmentId);
|
|
184
|
+
if (!subtree.size) return new Set();
|
|
185
|
+
const out = membersIn(networkId, subtree);
|
|
186
|
+
const leaders = db.all<{ department_id: string; leader_user_id: string | null }>(
|
|
187
|
+
"SELECT department_id, leader_user_id FROM network_departments WHERE network_id = ?1 AND leader_user_id IS NOT NULL",
|
|
188
|
+
networkId,
|
|
189
|
+
);
|
|
190
|
+
const memberIds = new Set(db.all<{ user_id: string }>("SELECT user_id FROM network_members WHERE network_id = ?1", networkId).map((r) => r.user_id));
|
|
191
|
+
for (const l of leaders) if (subtree.has(l.department_id) && memberIds.has(l.leader_user_id!)) out.add(l.leader_user_id!);
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 部门挂着的群(没有 → null)。 */
|
|
196
|
+
export function getDepartmentGroup(networkId: string, departmentId: string): ChatGroup | null {
|
|
197
|
+
const r = db.get<GroupRow>(`SELECT ${GROUP_COLS} FROM chat_groups WHERE network_id = ?1 AND department_id = ?2`, networkId, departmentId);
|
|
198
|
+
return r ? toPublic(r) : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export type CreateDepartmentGroupResult =
|
|
202
|
+
| { ok: true; group: ChatGroup; members: ChatGroupMember[] }
|
|
203
|
+
| (Fail & { group_id?: string });
|
|
204
|
+
|
|
205
|
+
/** 给部门建群。部门不存在 → 404;已经有群 → 409(带现有 group_id)。名字缺省 = 部门名。 */
|
|
206
|
+
export function createDepartmentGroup(networkId: string, departmentId: string, actorUserId: string, body: Record<string, unknown>): CreateDepartmentGroupResult {
|
|
207
|
+
const dept = db.get<{ name: string }>("SELECT name FROM network_departments WHERE network_id = ?1 AND department_id = ?2", networkId, departmentId);
|
|
208
|
+
if (!dept) return fail(404, "department_not_found");
|
|
209
|
+
let name = dept.name;
|
|
210
|
+
if (body.name !== undefined && body.name !== null && body.name !== "") {
|
|
211
|
+
if (typeof body.name !== "string") return fail(400, "invalid_group_name");
|
|
212
|
+
const n = body.name.trim();
|
|
213
|
+
if (!n || [...n].length > NAME_MAX) return fail(400, "invalid_group_name");
|
|
214
|
+
name = n;
|
|
215
|
+
}
|
|
216
|
+
const existing = getDepartmentGroup(networkId, departmentId);
|
|
217
|
+
if (existing) return { ...fail(409, "department_group_exists"), group_id: existing.id };
|
|
218
|
+
const groupId = `grp_${uuidv4().replace(/-/g, "").slice(0, 20)}`;
|
|
219
|
+
const roster = departmentGroupRoster(networkId, departmentId);
|
|
220
|
+
try {
|
|
221
|
+
groupTx(() => {
|
|
222
|
+
db.run(
|
|
223
|
+
"INSERT INTO chat_groups (group_id, network_id, name, department_id, created_by) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
224
|
+
[groupId, networkId, name, departmentId, actorUserId],
|
|
225
|
+
);
|
|
226
|
+
for (const uid of roster) {
|
|
227
|
+
db.run(
|
|
228
|
+
"INSERT INTO chat_group_members (group_id, user_id, network_id, source) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(group_id, user_id) DO NOTHING",
|
|
229
|
+
[groupId, uid, networkId, GROUP_SOURCE_DEPARTMENT],
|
|
230
|
+
);
|
|
231
|
+
recordMembershipChange({ network_id: networkId, group_id: groupId, user_id: uid, change: "added", source: "department" });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
} catch (err) {
|
|
235
|
+
// 并发的两次建群撞唯一索引:后到的那个按「已经有群」回。
|
|
236
|
+
const raced = getDepartmentGroup(networkId, departmentId);
|
|
237
|
+
if (raced) return { ...fail(409, "department_group_exists"), group_id: raced.id };
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
return { ok: true, group: toPublic(groupRow(networkId, groupId)!), members: listGroupMembers(groupId) };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** people 接口的 display_name 口径(requirements-people.ts):没设或等于 username → ""(App 自己回落到 username)。 */
|
|
244
|
+
export function publicDisplayName(username: string | null | undefined, displayName: string | null | undefined): string {
|
|
245
|
+
return !displayName || displayName === username ? "" : displayName;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
type MemberRow = { user_id: string; source: string; joined_at: string; username: string | null; display_name: string | null };
|
|
249
|
+
const MEMBER_SELECT = `SELECT m.user_id, m.source, m.joined_at, u.username, u.display_name
|
|
250
|
+
FROM chat_group_members m LEFT JOIN users u ON u.user_id = m.user_id`;
|
|
251
|
+
const toMember = (r: MemberRow): ChatGroupMember => ({
|
|
252
|
+
user_id: r.user_id, source: r.source, joined_at: r.joined_at,
|
|
253
|
+
username: r.username ?? "", display_name: publicDisplayName(r.username, r.display_name),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
export function listGroupMembers(groupId: string): ChatGroupMember[] {
|
|
257
|
+
return db.all<MemberRow>(`${MEMBER_SELECT} WHERE m.group_id = ?1 ORDER BY m.joined_at, m.user_id`, groupId).map(toMember);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** viewer_can,见 GroupViewerCan。isMember 由调用方给(列表里已经批量算过,不再逐行查)。 */
|
|
261
|
+
export function groupViewerCan(group: { department_id: string | null }, isMember: boolean, canManage: boolean, managedDepartments: ReadonlySet<string>): GroupViewerCan {
|
|
262
|
+
return { manage: canManageGroup(group, canManage, managedDepartments), post: isMember };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 我能看到的群:我在里面的;canManage(owner / admin / Hub 管理员)看到本网络全部。 */
|
|
266
|
+
export function listVisibleGroups(networkId: string, userId: string | null, canManage: boolean): Array<ChatGroup & { is_member: boolean }> {
|
|
267
|
+
const rows = db.all<GroupRow>(`SELECT ${GROUP_COLS} FROM chat_groups WHERE network_id = ?1 ORDER BY created_at, group_id`, networkId);
|
|
268
|
+
const mine = userId
|
|
269
|
+
? new Set(db.all<{ group_id: string }>("SELECT group_id FROM chat_group_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId).map((r) => r.group_id))
|
|
270
|
+
: new Set<string>();
|
|
271
|
+
return rows.filter((r) => canManage || mine.has(r.group_id)).map((r) => ({ ...toPublic(r), is_member: mine.has(r.group_id) }));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 读一个群:群成员、canManage,或挂部门的群的负责人(managedDepartments 含该部门)。别人一律 null(调用方回 404,
|
|
276
|
+
* 不暴露存在与否)。挂部门的群读之前先对账一次(兜底,§4),所以判「是不是成员」用的是对账后的结果。
|
|
277
|
+
*/
|
|
278
|
+
export function readGroup(networkId: string, groupId: string, userId: string | null, canManage: boolean, managedDepartments: ReadonlySet<string> = new Set()): { group: ChatGroup & { viewer_can: GroupViewerCan }; members: ChatGroupMember[]; is_member: boolean } | null {
|
|
279
|
+
const r = groupRow(networkId, groupId);
|
|
280
|
+
if (!r) return null;
|
|
281
|
+
if (r.department_id !== null) groupTx(() => { syncDepartmentGroups(networkId, groupId); });
|
|
282
|
+
const member = !!userId && isGroupMember(groupId, userId);
|
|
283
|
+
if (!member && !canManageGroup(r, canManage, managedDepartments)) return null;
|
|
284
|
+
return { group: { ...toPublic(r), viewer_can: groupViewerCan(r, member, canManage, managedDepartments) }, members: listGroupMembers(groupId), is_member: member };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 删部门时调用(和删部门同一事务):解除关联,群和成员留着。 */
|
|
288
|
+
export function unlinkDepartmentGroup(networkId: string, departmentId: string): void {
|
|
289
|
+
db.run("UPDATE chat_groups SET department_id = NULL, updated_at = datetime('now') WHERE network_id = ?1 AND department_id = ?2", [networkId, departmentId]);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 删网络时调用:群、成员、群消息、已读位置一起删。 */
|
|
293
|
+
export function deleteChatGroupsForNetwork(networkId: string): void {
|
|
294
|
+
db.run("DELETE FROM chat_group_reads WHERE network_id = ?1", [networkId]);
|
|
295
|
+
db.run("DELETE FROM chat_group_messages WHERE network_id = ?1", [networkId]);
|
|
296
|
+
db.run("DELETE FROM chat_group_members WHERE network_id = ?1", [networkId]);
|
|
297
|
+
db.run("DELETE FROM chat_groups WHERE network_id = ?1", [networkId]);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── 第二个 PR:成员同步(RFC-042 §4)──
|
|
301
|
+
|
|
302
|
+
type SyncResult = { added: number; removed: number };
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 对账本网络挂部门的群(onlyGroupId 给了就只对那一个)。调用方负责事务:它总在调人 / 改部门的同一个事务里被调用
|
|
306
|
+
* (db.transaction 可嵌套,PG 上是 savepoint)。树、成员、负责人各读一次,在内存里算(部门 ≤ 500)。
|
|
307
|
+
*/
|
|
308
|
+
export function syncDepartmentGroups(networkId: string, onlyGroupId?: string): SyncResult {
|
|
309
|
+
const groups = onlyGroupId === undefined
|
|
310
|
+
? db.all<{ group_id: string; department_id: string }>("SELECT group_id, department_id FROM chat_groups WHERE network_id = ?1 AND department_id IS NOT NULL", networkId)
|
|
311
|
+
: db.all<{ group_id: string; department_id: string }>("SELECT group_id, department_id FROM chat_groups WHERE network_id = ?1 AND group_id = ?2 AND department_id IS NOT NULL", networkId, onlyGroupId);
|
|
312
|
+
if (!groups.length) return { added: 0, removed: 0 };
|
|
313
|
+
const depts = db.all<{ department_id: string; parent_id: string | null; leader_user_id: string | null }>(
|
|
314
|
+
"SELECT department_id, parent_id, leader_user_id FROM network_departments WHERE network_id = ?1", networkId,
|
|
315
|
+
);
|
|
316
|
+
const members = db.all<{ user_id: string; department_id: string | null }>("SELECT user_id, department_id FROM network_members WHERE network_id = ?1", networkId);
|
|
317
|
+
const memberIds = new Set(members.map((m) => m.user_id));
|
|
318
|
+
const known = new Set(depts.map((d) => d.department_id));
|
|
319
|
+
const children = new Map<string, string[]>();
|
|
320
|
+
for (const d of depts) {
|
|
321
|
+
if (d.parent_id === null || !known.has(d.parent_id)) continue;
|
|
322
|
+
const list = children.get(d.parent_id) ?? [];
|
|
323
|
+
list.push(d.department_id);
|
|
324
|
+
children.set(d.parent_id, list);
|
|
325
|
+
}
|
|
326
|
+
const leaderOf = new Map(depts.map((d) => [d.department_id, d.leader_user_id]));
|
|
327
|
+
const byDept = new Map<string, string[]>();
|
|
328
|
+
for (const m of members) {
|
|
329
|
+
if (!m.department_id) continue;
|
|
330
|
+
const list = byDept.get(m.department_id) ?? [];
|
|
331
|
+
list.push(m.user_id);
|
|
332
|
+
byDept.set(m.department_id, list);
|
|
333
|
+
}
|
|
334
|
+
let added = 0;
|
|
335
|
+
let removed = 0;
|
|
336
|
+
for (const g of groups) {
|
|
337
|
+
// 和 departmentGroupRoster() 同一个口径:子树成员 ∪ 子树负责人(仍是网络成员)。部门不存在 → 空(删部门已先解除关联)。
|
|
338
|
+
const roster = new Set<string>();
|
|
339
|
+
if (known.has(g.department_id)) {
|
|
340
|
+
const stack = [g.department_id];
|
|
341
|
+
const seen = new Set<string>();
|
|
342
|
+
while (stack.length) {
|
|
343
|
+
const id = stack.pop()!;
|
|
344
|
+
if (seen.has(id)) continue;
|
|
345
|
+
seen.add(id);
|
|
346
|
+
for (const uid of byDept.get(id) ?? []) roster.add(uid);
|
|
347
|
+
const leader = leaderOf.get(id);
|
|
348
|
+
if (leader && memberIds.has(leader)) roster.add(leader);
|
|
349
|
+
for (const c of children.get(id) ?? []) stack.push(c);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const current = db.all<{ user_id: string; source: string }>("SELECT user_id, source FROM chat_group_members WHERE group_id = ?1", g.group_id);
|
|
353
|
+
const have = new Set(current.map((r) => r.user_id));
|
|
354
|
+
for (const uid of roster) {
|
|
355
|
+
if (have.has(uid)) continue; // 已在群里(含 manual 行:保持 manual,不升级)
|
|
356
|
+
db.run(
|
|
357
|
+
"INSERT INTO chat_group_members (group_id, user_id, network_id, source) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(group_id, user_id) DO NOTHING",
|
|
358
|
+
[g.group_id, uid, networkId, GROUP_SOURCE_DEPARTMENT],
|
|
359
|
+
);
|
|
360
|
+
recordMembershipChange({ network_id: networkId, group_id: g.group_id, user_id: uid, change: "added", source: "department" });
|
|
361
|
+
added++;
|
|
362
|
+
}
|
|
363
|
+
for (const r of current) {
|
|
364
|
+
if (r.source !== GROUP_SOURCE_DEPARTMENT || roster.has(r.user_id)) continue;
|
|
365
|
+
db.run("DELETE FROM chat_group_members WHERE group_id = ?1 AND user_id = ?2 AND source = ?3", [g.group_id, r.user_id, GROUP_SOURCE_DEPARTMENT]);
|
|
366
|
+
recordMembershipChange({ network_id: networkId, group_id: g.group_id, user_id: r.user_id, change: "removed", source: "department" });
|
|
367
|
+
removed++;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { added, removed };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 移出网络时调用(和删 network_members 同一事务):他在本网络所有群里的行都删,不论来源。 */
|
|
374
|
+
export function removeMemberFromChatGroups(networkId: string, userId: string): void {
|
|
375
|
+
const groups = db.all<{ group_id: string }>("SELECT group_id FROM chat_group_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
376
|
+
db.run("DELETE FROM chat_group_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
|
|
377
|
+
for (const g of groups) recordMembershipChange({ network_id: networkId, group_id: g.group_id, user_id: userId, change: "removed", source: "network_removal" });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* 谁能管一个群(改名 / 手动拉人、移人):网络 owner / admin / Hub 管理员;挂部门的群另加该部门(含上级)的负责人。
|
|
382
|
+
* managedDepartments = headScope(...).managed。
|
|
383
|
+
*/
|
|
384
|
+
export function canManageGroup(group: { department_id: string | null }, canManage: boolean, managedDepartments: ReadonlySet<string>): boolean {
|
|
385
|
+
return canManage || (group.department_id !== null && managedDepartments.has(group.department_id));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function groupById(networkId: string, groupId: string): ChatGroup | null {
|
|
389
|
+
const r = groupRow(networkId, groupId);
|
|
390
|
+
return r ? toPublic(r) : null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export type GroupWriteResult<T> = ({ ok: true } & T) | Fail;
|
|
394
|
+
|
|
395
|
+
export function renameGroup(networkId: string, groupId: string, body: Record<string, unknown>): GroupWriteResult<{ group: ChatGroup }> {
|
|
396
|
+
if (typeof body.name !== "string") return fail(400, "invalid_group_name");
|
|
397
|
+
const name = body.name.trim();
|
|
398
|
+
if (!name || [...name].length > NAME_MAX) return fail(400, "invalid_group_name");
|
|
399
|
+
db.run("UPDATE chat_groups SET name = ?3, updated_at = datetime('now') WHERE network_id = ?1 AND group_id = ?2", [networkId, groupId, name]);
|
|
400
|
+
return { ok: true, group: groupById(networkId, groupId)! };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** 手动拉人:必须是本网络成员(人);已在群里 → 409 already_group_member(不论来源)。 */
|
|
404
|
+
export function addManualMember(networkId: string, groupId: string, userId: unknown): GroupWriteResult<{ member: ChatGroupMember }> {
|
|
405
|
+
if (typeof userId !== "string" || !userId) return fail(400, "user_id_required");
|
|
406
|
+
if (!db.get("SELECT 1 AS x FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId)) return fail(400, "not_network_member");
|
|
407
|
+
if (isGroupMember(groupId, userId)) return fail(409, "already_group_member");
|
|
408
|
+
groupTx(() => {
|
|
409
|
+
const r = db.run(
|
|
410
|
+
"INSERT INTO chat_group_members (group_id, user_id, network_id, source) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(group_id, user_id) DO NOTHING",
|
|
411
|
+
[groupId, userId, networkId, GROUP_SOURCE_MANUAL],
|
|
412
|
+
);
|
|
413
|
+
if (r.changes > 0) recordMembershipChange({ network_id: networkId, group_id: groupId, user_id: userId, change: "added", source: "manual" });
|
|
414
|
+
});
|
|
415
|
+
const member = toMember(db.get<MemberRow>(`${MEMBER_SELECT} WHERE m.group_id = ?1 AND m.user_id = ?2`, groupId, userId)!);
|
|
416
|
+
return { ok: true, member };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 手动移人:只移 source='manual' 的行。source='department' → 409 department_member(下一次对账会把他加回来;
|
|
421
|
+
* 要移出请调整部门)。不在群里 → 404 group_member_not_found。解除关联的群(部门已删)不再对账,所以那里的行谁都能移。
|
|
422
|
+
*/
|
|
423
|
+
export function removeManualMember(networkId: string, groupId: string, userId: string): GroupWriteResult<{ user_id: string }> {
|
|
424
|
+
const group = groupRow(networkId, groupId);
|
|
425
|
+
if (!group) return fail(404, "group_not_found");
|
|
426
|
+
const row = db.get<{ source: string }>("SELECT source FROM chat_group_members WHERE group_id = ?1 AND user_id = ?2", groupId, userId);
|
|
427
|
+
if (!row) return fail(404, "group_member_not_found");
|
|
428
|
+
if (row.source === GROUP_SOURCE_DEPARTMENT && group.department_id !== null) return fail(409, "department_member");
|
|
429
|
+
groupTx(() => {
|
|
430
|
+
const r = db.run("DELETE FROM chat_group_members WHERE group_id = ?1 AND user_id = ?2", [groupId, userId]);
|
|
431
|
+
if (r.changes > 0) recordMembershipChange({ network_id: networkId, group_id: groupId, user_id: userId, change: "removed", source: "manual" });
|
|
432
|
+
});
|
|
433
|
+
return { ok: true, user_id: userId };
|
|
434
|
+
}
|
package/src/departments.ts
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
import { db, logAudit } from "./db.js";
|
|
14
14
|
import { deleteDepartmentGrants, deleteDepartmentGrantsForNetwork } from "./department-heads.js";
|
|
15
|
+
import { deleteChatGroupsForNetwork, groupTx, removeMemberFromChatGroups, syncDepartmentGroups, unlinkDepartmentGroup } from "./department-groups.js";
|
|
16
|
+
|
|
17
|
+
// auth.ts 的 removeNetworkMember 从这里拿(auth.ts 只 import departments.js 一行,文档钉着它的行号)。
|
|
18
|
+
export { groupTx, removeMemberFromChatGroups };
|
|
15
19
|
|
|
16
20
|
try { db.exec("ALTER TABLE network_members ADD COLUMN department_id TEXT"); } catch {}
|
|
17
21
|
db.exec(`
|
|
@@ -160,10 +164,14 @@ export function createDepartment(networkId: string, actor: string, body: Record<
|
|
|
160
164
|
...(parentId === null ? [networkId] : [networkId, parentId]),
|
|
161
165
|
)?.n ?? 0)
|
|
162
166
|
: body.sort as number;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
+
groupTx(() => {
|
|
168
|
+
db.run(
|
|
169
|
+
"INSERT INTO network_departments (network_id, department_id, name, parent_id, leader_user_id, sort, created_by) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
170
|
+
[networkId, id, name, parentId, leader, sort, actor],
|
|
171
|
+
);
|
|
172
|
+
// RFC-042 §4:新部门带负责人 → 上级部门群的 roster 多了这个负责人。没有负责人的新部门是空的,roster 不变。
|
|
173
|
+
if (leader !== null) syncDepartmentGroups(networkId);
|
|
174
|
+
});
|
|
167
175
|
return { ok: true, department: publicOne(networkId, id) };
|
|
168
176
|
}
|
|
169
177
|
|
|
@@ -198,10 +206,14 @@ export function updateDepartment(networkId: string, id: string, body: Record<str
|
|
|
198
206
|
}
|
|
199
207
|
if (!touched) return fail(400, "empty_patch");
|
|
200
208
|
if ((name !== cur.name || parentId !== cur.parent_id) && siblingNameTaken(networkId, parentId, name, id)) return fail(409, "department_name_taken");
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
209
|
+
groupTx(() => {
|
|
210
|
+
db.run(
|
|
211
|
+
"UPDATE network_departments SET name = ?3, parent_id = ?4, leader_user_id = ?5, sort = ?6, updated_at = datetime('now') WHERE network_id = ?1 AND department_id = ?2",
|
|
212
|
+
[networkId, id, name, parentId, leader, sort],
|
|
213
|
+
);
|
|
214
|
+
// RFC-042 §4:改上级(子树变了)/ 换负责人 → 同一事务对账部门群。只改名 / 排序不影响 roster。
|
|
215
|
+
if (parentId !== cur.parent_id || leader !== cur.leader_user_id) syncDepartmentGroups(networkId);
|
|
216
|
+
});
|
|
205
217
|
return { ok: true, department: publicOne(networkId, id) };
|
|
206
218
|
}
|
|
207
219
|
|
|
@@ -212,9 +224,13 @@ export function deleteDepartment(networkId: string, id: string): Ok<{ deleted: s
|
|
|
212
224
|
const members = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_members WHERE network_id = ?1 AND department_id = ?2", networkId, id)?.n ?? 0;
|
|
213
225
|
if (children || members) return { ...fail(409, "department_not_empty"), children, members };
|
|
214
226
|
// RFC-040:部门的项目授权(network_department_project_grants)和部门同一事务删掉。
|
|
215
|
-
|
|
227
|
+
groupTx(() => {
|
|
216
228
|
db.run("DELETE FROM network_departments WHERE network_id = ?1 AND department_id = ?2", [networkId, id]);
|
|
217
229
|
deleteDepartmentGrants(networkId, id);
|
|
230
|
+
// RFC-042:部门群解除关联(群和成员、以后的聊天记录都留着),不删。
|
|
231
|
+
unlinkDepartmentGroup(networkId, id);
|
|
232
|
+
// 删掉的部门的负责人可能还在上级部门群的 roster 里(负责人不必在本部门):对账一次。解除关联的群不再对账。
|
|
233
|
+
syncDepartmentGroups(networkId);
|
|
218
234
|
});
|
|
219
235
|
return { ok: true, deleted: id };
|
|
220
236
|
}
|
|
@@ -224,7 +240,11 @@ export function setMemberDepartment(networkId: string, userId: string, departmen
|
|
|
224
240
|
if (!isMember(networkId, userId)) return fail(404, "member_not_found");
|
|
225
241
|
const dept = departmentId === null || departmentId === "" ? null : departmentId;
|
|
226
242
|
if (dept !== null && (typeof dept !== "string" || !one(networkId, dept))) return fail(400, "department_not_found");
|
|
227
|
-
|
|
243
|
+
groupTx(() => {
|
|
244
|
+
db.run("UPDATE network_members SET department_id = ?3 WHERE network_id = ?1 AND user_id = ?2", [networkId, userId, dept]);
|
|
245
|
+
// RFC-042 §4:调进 / 调出 → 同一事务对账部门群(manual 行不动)。
|
|
246
|
+
syncDepartmentGroups(networkId);
|
|
247
|
+
});
|
|
228
248
|
return { ok: true, user_id: userId, department_id: dept as string | null };
|
|
229
249
|
}
|
|
230
250
|
|
|
@@ -232,6 +252,7 @@ export function setMemberDepartment(networkId: string, userId: string, departmen
|
|
|
232
252
|
export function deleteDepartmentsForNetwork(networkId: string): void {
|
|
233
253
|
db.run("DELETE FROM network_departments WHERE network_id = ?1", [networkId]);
|
|
234
254
|
deleteDepartmentGrantsForNetwork(networkId);
|
|
255
|
+
deleteChatGroupsForNetwork(networkId); // RFC-042:网络没了,群一起删
|
|
235
256
|
}
|
|
236
257
|
|
|
237
258
|
export function auditDepartment(user: { user_id: string; username: string }, action: string, networkId: string, detail: string): void {
|