diff --git a/docs/superpowers/plans/2026-07-09-org-tree-deepest-levels.md b/docs/superpowers/plans/2026-07-09-org-tree-deepest-levels.md new file mode 100644 index 0000000..c06dcc0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-org-tree-deepest-levels.md @@ -0,0 +1,174 @@ +# Org Tree Deepest Levels — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **Required skills per agent:** `superpowers:vercel-react-best-practices`, `superpowers:ui-ux-pro-max` + +**Goal:** `fetchOrgTreeWithUsers` only returns departments at the deepest 2 levels of the global org tree. + +**Architecture:** Add a `getDeptDepthMap` BFS method that mirrors `getAllDeptIds` but tracks level. Insert depth filtering in `fetchOrgTreeWithUsers` before the detail+user fetch loop, slashing API calls. + +**Tech Stack:** NestJS + TypeScript, DingTalk Open API v2 + +## Global Constraints + +- Depth is computed from rootDeptId=1 globally, regardless of the user's `rootDeptId` param +- `fetchOrgTree` (dept picker) is NOT modified +- `syncAll` is NOT modified +- Frontend receives the same `DingOrgTreeNodeWithUsers[]` shape, zero frontend changes + +--- + +### Task 1: Add `getDeptDepthMap` method + +**Files:** +- Modify: `apps/server/src/integration/dingtalk.service.ts` (insert after `getAllDeptIds`) + +**Interfaces:** +- Consumes: nothing new (uses existing `rateLimit()`, `SubDeptIdListResponse`, DingTalk `listsubid` API) +- Produces: `private async getDeptDepthMap(token: string): Promise>` — key=deptId, value=1-based depth + +- [ ] **Step 1: Add `getDeptDepthMap` after `getAllDeptIds` (after line 263)** + +```typescript + /** + * BFS from rootDeptId=1, returns depth of every department. + * Depth is 1-based (root=1). Uses listsubid API only, no detail fetches. + */ + private async getDeptDepthMap(token: string): Promise> { + const depthMap = new Map(); + const queue: Array<{ id: number; depth: number }> = [{ id: 1, depth: 1 }]; + + while (queue.length > 0) { + const { id, depth } = queue.shift()!; + depthMap.set(id, depth); + + try { + await this.rateLimit(); + const res = await fetch( + `https://oapi.dingtalk.com/topapi/v2/department/listsubid?access_token=${token}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dept_id: id }), + }, + ); + const body: SubDeptIdListResponse = await res.json(); + if (body.errcode === 0 && body.result?.dept_id_list) { + for (const childId of body.result.dept_id_list) { + queue.push({ id: childId, depth: depth + 1 }); + } + } + } catch (e) { + this.logger.error(`getDeptDepthMap 获取部门 ${id} 子部门失败: ${(e as Error).message}`); + } + } + + return depthMap; + } +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd apps/server && npx tsc --noEmit` +Expected: no new errors (may have pre-existing ones in other files) + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/integration/dingtalk.service.ts +git commit -m "feat: add getDeptDepthMap BFS method for org tree depth tracking" +``` + +--- + +### Task 2: Filter `fetchOrgTreeWithUsers` to deepest 2 levels + +**Files:** +- Modify: `apps/server/src/integration/dingtalk.service.ts:476-547` + +**Interfaces:** +- Consumes: `getDeptDepthMap(token)` from Task 1 +- Produces: same `Promise` return type, now filtered + +- [ ] **Step 1: Insert depth filtering before the detail+user fetch loop** + +Replace lines 480-481 (`const token = ...; const deptIds = ...;`) with the depth-aware version, and wrap the loop to use `filteredIds`: + +```typescript + const token = await this.getAccessToken(); + + // Compute global depth map (always from rootDeptId=1) + const depthMap = await this.getDeptDepthMap(token); + const maxDepth = Math.max(...depthMap.values()); + + // Get subtree IDs for the user's selected root + const subtreeIds = await this.getAllDeptIds(token, rootDeptId); + + // Filter: only keep departments at the deepest 2 levels of the global tree + const filteredIds = subtreeIds.filter((id) => { + const d = depthMap.get(id) ?? -1; + return d === maxDepth || d === maxDepth - 1; + }); + + if (filteredIds.length === 0) { + return []; + } + + // 拉每个部门的详情(仅过滤后的) + const nodes: DingOrgTreeNodeWithUsers[] = []; + const userDeptMap = new Map(); + + for (let i = 0; i < filteredIds.length; i++) { + if (i > 0) await this.delay(i); + const detail = await this.getDeptDetail(token, filteredIds[i]); +``` + +And update the deptId reference inside the loop body — line 494 uses `deptIds[i]`: + +Change line 494: +```typescript + // Before: + this.logger.log(`[dingtalk] dept ${deptIds[i]} (${detail.name}): ${dingUsers.length} users`); + // After: + this.logger.log(`[dingtalk] dept ${filteredIds[i]} (${detail.name}): ${dingUsers.length} users`); +``` + +And the `getDeptUsers` call on line 493 must also use `filteredIds[i]` instead of `deptIds[i]` — it already does since we're iterating `filteredIds`. + +- [ ] **Step 2: Update tree assembly to handle missing parent nodes** + +The tree assembly at lines 534-546 currently checks `node.id !== rootDeptId` to decide if a node is a root. Since intermediate levels are filtered out, a maxDepth-1 node's parent won't be in the `map`. Update the root detection: + +```typescript + // 组装成树(父节点可能已被过滤,缺失的父节点 → 节点提升为根) + const map = new Map(); + nodes.forEach((n) => map.set(n.id, n)); + const roots: DingOrgTreeNodeWithUsers[] = []; + for (const node of nodes) { + const parent = map.get(node.parentId); + if (parent) { + parent.children.push(node); + } else { + roots.push(node); + } + } +``` + +Note: removed the `&& node.id !== rootDeptId` condition — it's redundant with the `map.get` check when intermediate levels are filtered. Nodes whose parent exists in `map` get attached; those whose parent was filtered out become roots. Same behavior, simpler logic. + +- [ ] **Step 3: Verify it compiles** + +Run: `cd apps/server && npx tsc --noEmit` +Expected: no new errors + +- [ ] **Step 4: Smoke test with a real DingTalk config (if available)** + +Run: `curl -s http://localhost:3000/api/sync/dingtalk/org-tree-with-users | jq '. | length'` +Expected: returns departments; check that the response depth is at most 2 levels (manual inspection of `parentId` chains, or verify response size is smaller than before). + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/integration/dingtalk.service.ts +git commit -m "feat: filter org-tree-with-users to deepest 2 levels" +```