# Student Ding Mapping + Drawer Batch Operations — 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:** Replace UserDingMapping with StudentDingMapping, rewrite syncAll to create Students directly, add class batch-import endpoint, rebuild Drawer with checkable Tree + class list.

**Architecture:** Student becomes independent from User — sync creates Student + StudentDingMapping directly. Frontend Drawer uses Ant Design `<Tree checkable>` with left-right split layout for batch user selection and class operations.

**Tech Stack:** NestJS 11 + TypeORM 0.3 + Ant Design 6

## Global Constraints

- Student is independent of User (no userId, no RBAC)
- StudentDingMapping replaces UserDingMapping everywhere
- fetchOrgTree (dept picker) is NOT modified
- syncAll still syncs all departments — only user handling changes
- Depth filtering (getDeptDepthMap) is removed entirely

---

### Task 1: Create StudentDingMapping entity + swap in all modules

**Files:**
- Create: `apps/server/src/entities/student-ding-mapping.entity.ts`
- Modify: `apps/server/src/entities/index.ts`
- Modify: `apps/server/src/app.module.ts`
- Modify: `apps/server/src/integration/dingtalk.service.ts` — import + repo injection
- Modify: `apps/server/src/integration/integration.module.ts` — TypeOrmModule.forFeature
- Modify: `apps/server/src/sync/sync.service.ts` — import + repo injection
- Modify: `apps/server/src/sync/sync.module.ts` — TypeOrmModule.forFeature
- Modify: `apps/server/src/sync/schedule-sync.service.ts` — import + repo injection
- Modify: `apps/server/src/attendance/attendance-import.service.ts` — import + repo injection
- Modify: `apps/server/src/attendance/attendance.module.ts` — TypeOrmModule.forFeature
- Modify: `apps/server/src/attendance/attendance.service.ts` — import + repo injection
- Modify: `apps/server/src/rbac/rbac.service.ts` — import + repo injection
- Modify: `apps/server/src/rbac/rbac.module.ts` — TypeOrmModule.forFeature

**Interfaces:**
- Produces: `StudentDingMapping` entity with `id`, `dingUserId`(unique), `studentId`(unique), `student`(ManyToOne), `createdAt`
- Note: rbac controller endpoints (getUserDingMappings etc.) removed in Task 4

- [ ] **Step 1: Create `student-ding-mapping.entity.ts`**

```typescript
import {
  Entity, PrimaryGeneratedColumn, Column, CreateDateColumn,
  ManyToOne, JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';

@Entity('student_ding_mapping')
export class StudentDingMapping {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ name: 'ding_user_id', length: 100, unique: true })
  dingUserId: string;

  @Column({ name: 'student_id', type: 'integer', unique: true })
  studentId: number;

  @ManyToOne(() => Student, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'student_id' })
  student: Student;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}
```

- [ ] **Step 2: Swap exports in `entities/index.ts`**

Remove: `export { UserDingMapping } from './user-ding-mapping.entity';`
Add: `export { StudentDingMapping } from './student-ding-mapping.entity';`

- [ ] **Step 3: Swap in `app.module.ts`**

In the import from `'./entities'`: replace `UserDingMapping` with `StudentDingMapping`.
In the `TypeOrmModule.forRoot` entities array: same replacement.

- [ ] **Step 4: Swap in every module file**

For each file listed above, replace mechanically:
- `UserDingMapping` → `StudentDingMapping`
- `user_ding_mapping` → `student_ding_mapping`
- `mappingRepo` / `userDingMappingRepo` → `studentDingMappingRepo`

- [ ] **Step 5: Delete `user-ding-mapping.entity.ts`**

- [ ] **Step 6: Verify compile**

```bash
cd apps/server && npx tsc --noEmit
```

Expected: zero new errors (pre-existing spec-file errors ignored).

- [ ] **Step 7: Commit**

```bash
git add apps/server/src/entities/ apps/server/src/app.module.ts apps/server/src/integration/ apps/server/src/sync/ apps/server/src/attendance/ apps/server/src/rbac/
git commit -m "refactor: replace UserDingMapping with StudentDingMapping entity"
```

---

### Task 2: Rewrite syncAll to create Student directly

**Files:**
- Modify: `apps/server/src/integration/dingtalk.service.ts` — `syncAll` and `syncOneUser`

**Interfaces:**
- Consumes: `StudentDingMapping` entity (Task 1)
- Produces: same `{ deptCount, userCount }` return type

- [ ] **Step 1: Rewrite `syncOneUser`**

Replace existing `syncOneUser` (lines ~553-624) with:

```typescript
private async syncOneUser(du: {
  userid: string; name: string; mobile: string;
}): Promise<void> {
  let mapping = await this.studentDingMappingRepo.findOne({
    where: { dingUserId: du.userid },
  });
  if (mapping) {
    const student = await this.studentRepo.findOne({
      where: { id: mapping.studentId },
    });
    if (student) {
      student.name = du.name;
      if (du.mobile) student.phone = du.mobile;
      await this.studentRepo.save(student);
    }
    return;
  }

  const student = this.studentRepo.create({
    name: du.name,
    phone: du.mobile || undefined,
    status: 'active',
  });
  await this.studentRepo.save(student);

  mapping = this.studentDingMappingRepo.create({
    dingUserId: du.userid,
    studentId: student.id,
  });
  await this.studentDingMappingRepo.save(mapping);
}
```

Remove unused imports: `bcrypt`, `User`, `userRepo` injection. `studentDingMappingRepo` already injected from Task 1.

- [ ] **Step 2: Verify compile**

```bash
cd apps/server && npx tsc --noEmit
```

- [ ] **Step 3: Commit**

```bash
git add apps/server/src/integration/dingtalk.service.ts
git commit -m "refactor: syncAll creates Student + StudentDingMapping directly"
```

---

### Task 3: Revert deepest-2-levels filtering

**Files:**
- Modify: `apps/server/src/integration/dingtalk.service.ts` — `fetchOrgTreeWithUsers` + remove `getDeptDepthMap`

- [ ] **Step 1: Remove `getDeptDepthMap` method** (the ~37 lines added in commit 0509175)

- [ ] **Step 2: Restore `fetchOrgTreeWithUsers` loop**

Remove depthMap/maxDepth/filteredIds. Restore:
```typescript
const deptIds = await this.getAllDeptIds(token, rootDeptId);
```
And `for (let i = 0; i < deptIds.length; i++)` instead of `filteredIds`.

- [ ] **Step 3: Restore tree assembly condition**

Add back `&& node.id !== rootDeptId` in the root-detection line.

- [ ] **Step 4: Verify compile + commit**

```bash
cd apps/server && npx tsc --noEmit
git add apps/server/src/integration/dingtalk.service.ts
git commit -m "revert: remove deepest-2-levels filter, restore full org tree"
```

---

### Task 4: Remove User-based import + RBAC UserDingMapping endpoints

**Files:**
- Modify: `apps/server/src/sync/sync.service.ts` — remove `importDingTalkUsers`, `ImportUserDto`
- Modify: `apps/server/src/sync/sync.controller.ts` — remove `POST /dingtalk/import-users`
- Modify: `apps/server/src/sync/dto/import-users.dto.ts` — remove `ImportUsersDto`
- Modify: `apps/server/src/rbac/rbac.service.ts` — remove `getUserDingMappings`, `createUserDingMapping`, `deleteUserDingMapping`, unbound-users query
- Modify: `apps/server/src/rbac/rbac.controller.ts` — remove corresponding endpoints + imports
- Modify: `apps/server/src/rbac/dto/rbac.dto.ts` — remove `CreateUserDingMappingDto`

- [ ] **Step 1: Remove importDingTalkUsers from sync.service.ts**

Delete the entire `importDingTalkUsers` method and the `ImportUserDto` interface.
Remove now-unused imports: `Role`, `bcrypt`, `ClassTeacher`, `ClassStudent`, `Department`, `ClassEntity`, `BadRequestException`.

- [ ] **Step 2: Remove import endpoint from sync.controller.ts**

Delete the `POST /dingtalk/import-users` handler and `ImportUsersDto` import.

- [ ] **Step 3: Remove RBAC UserDingMapping methods**

In `rbac.service.ts`: delete `getUserDingMappings()`, `createUserDingMapping()`, `deleteUserDingMapping()`, and the unbound-users query.
In `rbac.controller.ts`: delete `GET /user-ding-mappings`, `POST /user-ding-mappings`, `DELETE /user-ding-mappings/:id`, `GET /user-ding-mappings/unbound-users`.
In `rbac.dto.ts`: delete `CreateUserDingMappingDto`.

- [ ] **Step 4: Verify compile**

```bash
cd apps/server && npx tsc --noEmit
```

- [ ] **Step 5: Commit**

```bash
git add apps/server/src/sync/ apps/server/src/rbac/
git commit -m "refactor: remove User-based import and RBAC UserDingMapping endpoints"
```

---

### Task 5: Add class batch-import + extend class create

**Files:**
- Modify: `apps/server/src/classes/classes.service.ts` — new `batchImportStudents`, extend `create`
- Modify: `apps/server/src/classes/classes.controller.ts` — new endpoint
- Modify: `apps/server/src/classes/dto/class.dto.ts` — new DTOs
- Modify: `apps/server/src/classes/classes.module.ts` — add StudentDingMapping

**Interfaces:**
- `POST /classes/:id/students/import` — body `{ dingUserIds: string[] }` → `{ imported: number, skipped: number }`
- `POST /classes` — extended: optional `dingUserIds: string[]`

- [ ] **Step 1: Add DTOs in `class.dto.ts`**

```typescript
import { IsArray, IsString, ArrayNotEmpty, IsOptional } from 'class-validator';

export class BatchImportStudentsDto {
  @IsArray()
  @IsString({ each: true })
  @ArrayNotEmpty()
  dingUserIds: string[];
}
```

In existing `CreateClassDto`, add:
```typescript
@IsArray()
@IsString({ each: true })
@IsOptional()
dingUserIds?: string[];
```

- [ ] **Step 2: Inject `StudentDingMapping` in classes.service.ts**

```typescript
import { StudentDingMapping } from '../entities';
// ...
@InjectRepository(StudentDingMapping)
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
```

- [ ] **Step 3: Add `batchImportStudents` method**

```typescript
async batchImportStudents(classId: number, dingUserIds: string[]): Promise<{ imported: number; skipped: number }> {
  const classEntity = await this.classRepo.findOne({ where: { id: classId } });
  if (!classEntity) throw new NotFoundException('班级不存在');

  let imported = 0;
  let skipped = 0;

  for (const dingUserId of dingUserIds) {
    let mapping = await this.studentDingMappingRepo.findOne({ where: { dingUserId } });
    let studentId: number;

    if (mapping) {
      studentId = mapping.studentId;
    } else {
      const student = this.studentRepo.create({
        name: `dd_${dingUserId}`,
        status: 'active',
        departmentId: classEntity.departmentId ?? undefined,
      });
      const saved = await this.studentRepo.save(student);
      studentId = saved.id;
      mapping = this.studentDingMappingRepo.create({ dingUserId, studentId });
      await this.studentDingMappingRepo.save(mapping);
    }

    const existing = await this.classStudentRepo.findOne({
      where: { classId, studentId },
    });
    if (existing) { skipped++; continue; }

    await this.classStudentRepo.save(
      this.classStudentRepo.create({
        classId, studentId, status: 'active',
        joinDate: new Date().toISOString().slice(0, 10),
      }),
    );
    imported++;
  }

  return { imported, skipped };
}
```

- [ ] **Step 4: Extend `create` method**

At end of `create`, before return:
```typescript
if (dto.dingUserIds?.length) {
  await this.batchImportStudents(saved.id, dto.dingUserIds);
}
```

- [ ] **Step 5: Add endpoint in classes.controller.ts**

```typescript
import { BatchImportStudentsDto } from './dto/class.dto';

@Post(':id/students/import')
@RequirePermission('class:edit')
async batchImportStudents(
  @Param('id') id: string,
  @Body() dto: BatchImportStudentsDto,
) {
  return this.classesService.batchImportStudents(+id, dto.dingUserIds);
}
```

- [ ] **Step 6: Add StudentDingMapping to classes.module.ts**

```typescript
TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping]),
```

- [ ] **Step 7: Verify compile + commit**

```bash
cd apps/server && npx tsc --noEmit
git add apps/server/src/classes/
git commit -m "feat: add batch-import students to class endpoint"
```

---

### Task 6: Frontend — rewrite Drawer with checkable Tree + class list

**Files:**
- Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx`

**Interfaces:**
- Consumes: `GET /sync/dingtalk/org-tree-with-users`, `GET /classes`, `POST /classes/:id/students/import`, `POST /classes`
- Produces: redesigned Drawer with left-right split layout

- [ ] **Step 1: Update imports**

```typescript
import { Tree, Row, Col, Card, List, Button, Space, Drawer, Alert, TreeSelect, message, Modal, Form, Input, DatePicker, InputNumber, Select, Tag } from 'antd';
import { SyncOutlined, BankOutlined, UserOutlined } from '@ant-design/icons';
```

Remove: `ReloadOutlined` (no longer used), `Spin`, `Descriptions`, etc.

- [ ] **Step 2: Replace state**

```typescript
// Remove: teacherChecks, teacherRoles, defaultTeacherRoleId, roles, classMarks, classModalDept
// Add:
const [checkedKeys, setCheckedKeys] = useState<React.Key[]>([]);
const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
const [classes, setClasses] = useState<any[]>([]);
const [classForm] = Form.useForm();
const [classModalOpen, setClassModalOpen] = useState(false);
const [importing, setImporting] = useState(false);
```

- [ ] **Step 3: Replace `buildTreeData` (checkable, no toggles)**

```typescript
const buildTreeData = useCallback((nodes: any[]): any[] => {
  return nodes.map((node) => ({
    title: (
      <Space size="small">
        <BankOutlined />
        <span>{node.name}</span>
        <Tag>{node.users.length}人</Tag>
      </Space>
    ),
    key: `dept-${node.id}`,
    children: [
      ...buildTreeData(node.children),
      ...node.users.map((u: any) => ({
        title: <Space><UserOutlined /><span>{u.name}</span><Tag>{u.mobile}</Tag></Space>,
        key: `user-${u.userid}`,
      })),
    ],
  }));
}, []);
```

- [ ] **Step 4: Add `fetchClasses`**

```typescript
const fetchClasses = async () => {
  try {
    const res = await api.get('/classes');
    setClasses(Array.isArray(res) ? res : res.data ?? []);
  } catch { /* ignore */ }
};
```

Call `fetchClasses()` inside `handleFetchOrgTree`.

- [ ] **Step 5: `handleJoinClass`**

```typescript
const handleJoinClass = async () => {
  if (selectedClassId === null) return message.warning('请先选择一个班级');
  const userIds = checkedKeys
    .filter((k) => String(k).startsWith('user-'))
    .map((k) => String(k).replace('user-', ''));
  if (userIds.length === 0) return message.warning('请勾选要导入的用户');

  setImporting(true);
  try {
    const res = await api.post(`/classes/${selectedClassId}/students/import`, { dingUserIds: userIds });
    message.success(`导入 ${res.imported} 人，跳过 ${res.skipped} 人`);
    setCheckedKeys([]);
    setSelectedClassId(null);
  } catch (e: any) {
    message.error(e?.message || '导入失败');
  } finally {
    setImporting(false);
  }
};
```

- [ ] **Step 6: `handleCreateClass` modal**

```typescript
const handleCreateClass = async () => {
  try {
    const values = await classForm.validateFields();
    const userIds = checkedKeys
      .filter((k) => String(k).startsWith('user-'))
      .map((k) => String(k).replace('user-', ''));
    const deptKeys = checkedKeys.filter((k) => String(k).startsWith('dept-'));
    const deptId = deptKeys.length > 0
      ? Number(String(deptKeys[0]).replace('dept-', ''))
      : undefined;

    setImporting(true);
    await api.post('/classes', { ...values, dingUserIds: userIds, departmentId: deptId });
    message.success('班级创建成功');
    setClassModalOpen(false);
    classForm.resetFields();
    setCheckedKeys([]);
    fetchClasses();
  } catch (e: any) {
    message.error(e?.message || '创建失败');
  } finally {
    setImporting(false);
  }
};
```

- [ ] **Step 7: Render Drawer with left-right layout**

```tsx
<Drawer
  title="钉钉组织架构 — 批量导入"
  open={drawerOpen}
  onClose={() => { setDrawerOpen(false); }}
  width={900}
  footer={
    <Space>
      <Button onClick={() => { setDrawerOpen(false); }}>取消</Button>
      <Button
        type="primary"
        loading={importing}
        disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0 || selectedClassId === null}
        onClick={handleJoinClass}
      >加入选中的班级</Button>
      <Button
        disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0}
        onClick={() => setClassModalOpen(true)}
      >创建班级</Button>
    </Space>
  }
>
  <Row gutter={16}>
    <Col span={14}>
      <div style={{ maxHeight: '60vh', overflow: 'auto' }}>
        <Tree
          checkable
          treeData={treeData}
          defaultExpandAll
          showLine={{ showLeafIcon: false }}
          checkedKeys={checkedKeys}
          onCheck={(checked) => setCheckedKeys(checked as React.Key[])}
        />
      </div>
    </Col>
    <Col span={10}>
      <Card title="班级列表" size="small"
        extra={<Button size="small" onClick={() => setClassModalOpen(true)}>+ 创建班级</Button>}>
        <List
          dataSource={classes}
          renderItem={(cls: any) => (
            <List.Item
              onClick={() => setSelectedClassId(cls.id)}
              style={{
                cursor: 'pointer',
                background: selectedClassId === cls.id ? '#e6f4ff' : undefined,
                borderRadius: 4,
                padding: '8px 12px',
              }}
            >
              <List.Item.Meta title={cls.name} description={`${cls.code}  ${cls.classType || ''}`} />
            </List.Item>
          )}
        />
      </Card>
    </Col>
  </Row>

  {/* 创建班级 Modal */}
  <Modal
    title="创建班级"
    open={classModalOpen}
    onOk={handleCreateClass}
    onCancel={() => { setClassModalOpen(false); classForm.resetFields(); }}
    confirmLoading={importing}
    destroyOnClose
  >
    <Form form={classForm} layout="vertical">
      <Form.Item name="name" label="班级名称" rules={[{ required: true }]}>
        <Input />
      </Form.Item>
      <Form.Item name="code" label="班级编码" rules={[{ required: true }]}>
        <Input placeholder="如 CS2024-01" />
      </Form.Item>
      <Form.Item name="classType" label="班型" rules={[{ required: true }]}>
        <Select options={[
          { value: 'culture', label: '文化课' },
          { value: 'professional', label: '专业课' },
          { value: 'bootcamp', label: '集训营' },
          { value: 'sprint', label: '冲刺班' },
        ]} />
      </Form.Item>
      <Form.Item name="startDate" label="开班日期">
        <DatePicker style={{ width: '100%' }} />
      </Form.Item>
      <Form.Item name="endDate" label="结束日期">
        <DatePicker style={{ width: '100%' }} />
      </Form.Item>
      <Form.Item name="maxStudents" label="最大人数">
        <InputNumber min={0} style={{ width: '100%' }} placeholder="0=不限制" />
      </Form.Item>
      <Form.Item name="notes" label="备注">
        <Input.TextArea rows={2} />
      </Form.Item>
    </Form>
  </Modal>
</Drawer>
```

- [ ] **Step 8: Delete old code**

Remove: `UserTreeNode` component, `teacherChecks`/`teacherRoles`/`classMarks`/`classModalDept` state, `handleToggleTeacher`/`handleClassModalOk`/`handleClassModalCancel`/`handleImportUsers` callbacks, `buildTreeData` (old version), per-department class mark modal, role fetch logic.

- [ ] **Step 9: Verify compile**

```bash
cd apps/admin && npx tsc --noEmit
```

- [ ] **Step 10: Commit**

```bash
git add apps/admin/src/pages/IntegrationConfig/index.tsx
git commit -m "feat: rewrite sync drawer with checkable tree + class batch import"
```

---

### Task 7: Cleanup tests

**Files:**
- Modify: `apps/server/src/sync/sync.service.spec.ts` — swap UserDingMapping → StudentDingMapping, remove User-related mocks
- Modify: `apps/server/src/attendance/attendance.service.spec.ts` — same swap

- [ ] **Step 1: Fix sync.service.spec.ts**

Replace all `UserDingMapping` with `StudentDingMapping`. Remove `userRepo`, `roleRepo`, `bcrypt` mocks. Update test cases that tested `importDingTalkUsers` — those tests are removed (the method is gone in Task 4). Keep only remaining tests.

- [ ] **Step 2: Fix attendance.service.spec.ts**

Replace `UserDingMapping` with `StudentDingMapping` in imports and mock providers.

- [ ] **Step 3: Verify compile**

```bash
cd apps/server && npx tsc --noEmit
cd apps/admin && npx tsc --noEmit
```

- [ ] **Step 4: Commit**

```bash
git add apps/server/src/sync/sync.service.spec.ts apps/server/src/attendance/attendance.service.spec.ts
git commit -m "chore: update tests for StudentDingMapping"
```
