• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

teableio / teable / 29432643754

15 Jul 2026 04:29PM UTC coverage: 54.058% (-4.9%) from 58.999%
29432643754

Pull #3603

github

web-flow
Merge a94edc203 into 0bced45ab
Pull Request #3603: [sync] fix: preserve select lookup choices on lookupOptions update (T6208)

7440 of 10276 branches covered (72.4%)

17 of 18 new or added lines in 3 files covered. (94.44%)

2955 existing lines in 145 files now uncovered.

32156 of 59484 relevant lines covered (54.06%)

2325.52 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.27
/apps/nestjs-backend/src/features/base-node/base-node.controller.ts
1
/* eslint-disable sonarjs/no-duplicate-string */
2
import {
3
  Body,
4
  Controller,
5
  Delete,
6
  Get,
7
  Headers,
8
  Param,
9
  Post,
10
  Put,
11
  Res,
12
  UseGuards,
13
} from '@nestjs/common';
14
import {
15
  BaseNodeResourceType,
16
  moveBaseNodeRoSchema,
17
  createBaseNodeRoSchema,
18
  duplicateBaseNodeRoSchema,
19
  ICreateBaseNodeRo,
20
  IDuplicateBaseNodeRo,
21
  IMoveBaseNodeRo,
22
  updateBaseNodeRoSchema,
23
  IUpdateBaseNodeRo,
24
  type IBaseNodeTreeVo,
25
  type IBaseNodeVo,
26
  type IDeleteBaseNodeVo,
27
  type V2Feature,
28
} from '@teable/openapi';
29
import type { Response } from 'express';
30
import { ClsService } from 'nestjs-cls';
31
import { EmitControllerEvent } from '../../event-emitter/decorators/emit-controller-event.decorator';
32
import { Events } from '../../event-emitter/events';
33
import type { IClsStore } from '../../types/cls';
34
import { ZodValidationPipe } from '../../zod.validation.pipe';
35
import { AllowAnonymous, AllowAnonymousType } from '../auth/decorators/allow-anonymous.decorator';
36
import { BaseNodePermissions } from '../auth/decorators/base-node-permissions.decorator';
37
import { Permissions } from '../auth/decorators/permissions.decorator';
38
import { BaseNodePermissionGuard } from '../auth/guard/base-node-permission.guard';
39
import type { IV2Decision } from '../canary/canary.service';
40
import {
41
  X_TEABLE_V2_FEATURE_HEADER,
42
  X_TEABLE_V2_HEADER,
43
  X_TEABLE_V2_REASON_HEADER,
44
} from '../canary/interceptors/v2-indicator.interceptor';
45
import { checkBaseNodePermission } from './base-node.permission.helper';
46
import { BaseNodeService } from './base-node.service';
47
import { BaseNodeAction } from './types';
48

49
@Controller('api/base/:baseId/node')
50
@UseGuards(BaseNodePermissionGuard)
51
@AllowAnonymous(AllowAnonymousType.RESOURCE)
52
export class BaseNodeController {
53
  protected static readonly createTableV2Feature = 'createTable';
244✔
54
  protected static readonly duplicateTableV2Feature = 'duplicateTable';
244✔
55
  protected static readonly deleteTableV2Feature = 'deleteTable';
244✔
56

57
  constructor(
58
    private readonly baseNodeService: BaseNodeService,
132✔
59
    private readonly cls: ClsService<IClsStore>
132✔
60
  ) {}
61

62
  @Get('list')
63
  @Permissions('base|read')
64
  async getList(@Param('baseId') baseId: string): Promise<IBaseNodeVo[]> {
65
    const permissionContext = await this.getPermissionContext(baseId);
16✔
66
    const nodeList = await this.baseNodeService.getList(baseId);
16✔
67
    const allowedNodeIds = this.getAllowedNodeIds(nodeList, permissionContext.shareNodeId);
16✔
68
    return nodeList.filter((node) => this.filterNode(node, permissionContext, allowedNodeIds));
41✔
69
  }
70

71
  @Get('tree')
72
  @Permissions('base|read')
73
  async getTree(@Param('baseId') baseId: string): Promise<IBaseNodeTreeVo> {
74
    const permissionContext = await this.getPermissionContext(baseId);
20✔
75
    const tree = await this.baseNodeService.getTree(baseId);
20✔
76
    const allowedNodeIds = this.getAllowedNodeIds(tree.nodes, permissionContext.shareNodeId);
20✔
77
    return {
20✔
78
      ...tree,
79
      nodes: tree.nodes.filter((node) => this.filterNode(node, permissionContext, allowedNodeIds)),
64✔
80
    };
81
  }
82

83
  private filterNode(
84
    node: IBaseNodeVo,
85
    permissionContext: { permissionSet: Set<string>; shareNodeId?: string },
86
    allowedNodeIds?: Set<string>
87
  ): boolean {
88
    if (allowedNodeIds && !allowedNodeIds.has(node.id)) {
105✔
89
      return false;
5✔
90
    }
91

92
    // Then check standard permissions
93
    return checkBaseNodePermission(
100✔
94
      { resourceType: node.resourceType, resourceId: node.resourceId },
95
      BaseNodeAction.Read,
96
      permissionContext
97
    );
98
  }
99

100
  protected getAllowedNodeIds(nodes: IBaseNodeVo[], shareNodeId?: string) {
101
    if (!shareNodeId) {
36✔
102
      return undefined;
32✔
103
    }
104
    const nodeIds = new Set(nodes.map((node) => node.id));
12✔
105
    if (!nodeIds.has(shareNodeId)) {
4✔
106
      return new Set<string>();
×
107
    }
108
    const childrenByParent = new Map<string, string[]>();
4✔
109
    for (const node of nodes) {
4✔
110
      if (!node.parentId) {
12✔
111
        continue;
8✔
112
      }
113
      const current = childrenByParent.get(node.parentId) ?? [];
4✔
114
      current.push(node.id);
12✔
115
      childrenByParent.set(node.parentId, current);
12✔
116
    }
117
    const allowed = new Set<string>();
4✔
118
    const queue = [shareNodeId];
4✔
119
    while (queue.length) {
4✔
120
      const current = queue.shift();
7✔
121
      if (!current || allowed.has(current)) {
7✔
122
        continue;
×
123
      }
124
      allowed.add(current);
7✔
125
      const children = childrenByParent.get(current) ?? [];
7✔
126
      for (const childId of children) {
7✔
127
        if (!allowed.has(childId)) {
3✔
128
          queue.push(childId);
3✔
129
        }
130
      }
131
    }
132
    return allowed;
4✔
133
  }
134

135
  @Get(':nodeId')
136
  @Permissions('base|read')
137
  @BaseNodePermissions(BaseNodeAction.Read)
138
  async getNode(
139
    @Param('baseId') baseId: string,
140
    @Param('nodeId') nodeId: string
141
  ): Promise<IBaseNodeVo> {
UNCOV
142
    return this.baseNodeService.getNodeVo(baseId, nodeId);
×
143
  }
144

145
  @Post()
146
  @Permissions('base|read')
147
  @BaseNodePermissions(BaseNodeAction.Create)
148
  @EmitControllerEvent(Events.BASE_NODE_CREATE)
149
  async create(
150
    @Param('baseId') baseId: string,
151
    @Body(new ZodValidationPipe(createBaseNodeRoSchema)) ro: ICreateBaseNodeRo,
152
    @Headers('x-window-id') windowId: string | undefined,
153
    @Res({ passthrough: true }) response: Response
154
  ): Promise<IBaseNodeVo> {
155
    await this.prepareCreateTableCanary(baseId, ro, response, windowId);
38✔
156
    return this.baseNodeService.create(baseId, ro);
38✔
157
  }
158

159
  @Post(':nodeId/duplicate')
160
  @Permissions('base|read')
161
  @BaseNodePermissions(BaseNodeAction.Read, BaseNodeAction.Create)
162
  @EmitControllerEvent(Events.BASE_NODE_CREATE)
163
  async duplicate(
164
    @Param('baseId') baseId: string,
165
    @Param('nodeId') nodeId: string,
166
    @Body(new ZodValidationPipe(duplicateBaseNodeRoSchema)) ro: IDuplicateBaseNodeRo,
167
    @Headers('x-window-id') windowId: string | undefined,
168
    @Res({ passthrough: true }) response: Response
169
  ): Promise<IBaseNodeVo> {
UNCOV
170
    await this.prepareDuplicateTableCanary(baseId, nodeId, response, windowId);
×
UNCOV
171
    return this.baseNodeService.duplicate(baseId, nodeId, ro);
×
172
  }
173

174
  @Put(':nodeId')
175
  @Permissions('base|read')
176
  @BaseNodePermissions(BaseNodeAction.Update)
177
  @EmitControllerEvent(Events.BASE_NODE_UPDATE)
178
  async update(
179
    @Param('baseId') baseId: string,
180
    @Param('nodeId') nodeId: string,
181
    @Body(new ZodValidationPipe(updateBaseNodeRoSchema)) ro: IUpdateBaseNodeRo
182
  ): Promise<IBaseNodeVo> {
UNCOV
183
    return this.baseNodeService.update(baseId, nodeId, ro);
×
184
  }
185

186
  @Put(':nodeId/move')
187
  @Permissions('base|update')
188
  async move(
189
    @Param('baseId') baseId: string,
190
    @Param('nodeId') nodeId: string,
191
    @Body(new ZodValidationPipe(moveBaseNodeRoSchema)) ro: IMoveBaseNodeRo
192
  ): Promise<IBaseNodeVo> {
193
    return this.baseNodeService.move(baseId, nodeId, ro);
18✔
194
  }
195

196
  @Delete(':nodeId')
197
  @Permissions('base|read')
198
  @BaseNodePermissions(BaseNodeAction.Delete)
199
  @EmitControllerEvent(Events.BASE_NODE_DELETE)
200
  async delete(
201
    @Param('baseId') baseId: string,
202
    @Param('nodeId') nodeId: string,
203
    @Headers('x-window-id') windowId: string | undefined,
204
    @Res({ passthrough: true }) response: Response
205
  ): Promise<IDeleteBaseNodeVo> {
206
    await this.prepareDeleteTableCanary(baseId, nodeId, response, windowId);
3✔
207
    return this.baseNodeService.delete(baseId, nodeId);
3✔
208
  }
209

210
  @Delete(':nodeId/permanent')
211
  @Permissions('base|read')
212
  @BaseNodePermissions(BaseNodeAction.Delete)
213
  @EmitControllerEvent(Events.BASE_NODE_DELETE)
214
  async permanentDelete(
215
    @Param('baseId') baseId: string,
216
    @Param('nodeId') nodeId: string,
217
    @Headers('x-window-id') windowId: string | undefined,
218
    @Res({ passthrough: true }) response: Response
219
  ): Promise<IDeleteBaseNodeVo> {
220
    await this.prepareDeleteTableCanary(baseId, nodeId, response, windowId);
×
221
    const result = await this.baseNodeService.delete(baseId, nodeId, true);
×
222
    return { ...result, permanent: true };
×
223
  }
224

225
  protected async prepareDeleteTableCanary(
226
    baseId: string,
227
    nodeId: string,
228
    response: Response,
229
    windowId?: string
230
  ): Promise<void> {
231
    await this.prepareTableNodeCanary(
3✔
232
      baseId,
233
      nodeId,
234
      response,
235
      BaseNodeController.deleteTableV2Feature,
236
      windowId
237
    );
238
  }
239

240
  protected async prepareDuplicateTableCanary(
241
    baseId: string,
242
    nodeId: string,
243
    response: Response,
244
    windowId?: string
245
  ): Promise<void> {
UNCOV
246
    await this.prepareTableNodeCanary(
×
247
      baseId,
248
      nodeId,
249
      response,
250
      BaseNodeController.duplicateTableV2Feature,
251
      windowId
252
    );
253
  }
254

255
  protected async prepareCreateTableCanary(
256
    baseId: string,
257
    createRo: ICreateBaseNodeRo,
258
    response: Response,
259
    windowId?: string
260
  ): Promise<void> {
261
    if (windowId) {
38✔
UNCOV
262
      this.cls.set('windowId', windowId);
×
263
    }
264

265
    if (createRo.resourceType !== BaseNodeResourceType.Table) {
38✔
266
      return;
23✔
267
    }
268

269
    const decision = await this.baseNodeService.getCreateTableV2Decision(baseId);
15✔
270
    if (!decision) {
15✔
271
      return;
×
272
    }
273

274
    this.applyV2Decision(response, BaseNodeController.createTableV2Feature, decision);
15✔
275
  }
276

277
  protected async prepareTableNodeCanary(
278
    baseId: string,
279
    nodeId: string,
280
    response: Response,
281
    feature: V2Feature,
282
    windowId?: string
283
  ): Promise<void> {
284
    if (windowId) {
3✔
UNCOV
285
      this.cls.set('windowId', windowId);
×
286
    }
287

288
    const node = await this.baseNodeService.getNode(baseId, nodeId);
3✔
289
    if (node.resourceType !== BaseNodeResourceType.Table) {
3✔
290
      return;
3✔
291
    }
292

UNCOV
293
    const decision = await this.baseNodeService.getTableV2Decision(baseId, nodeId, feature);
×
UNCOV
294
    if (!decision) {
×
295
      return;
×
296
    }
297

UNCOV
298
    this.applyV2Decision(response, feature, decision);
×
299
  }
300

301
  protected applyV2Decision(response: Response, feature: V2Feature, decision: IV2Decision) {
302
    this.cls.set('useV2', decision.useV2);
15✔
303
    this.cls.set('v2Feature', feature);
15✔
304
    this.cls.set('v2Reason', decision.reason);
15✔
305

306
    response.setHeader(X_TEABLE_V2_HEADER, decision.useV2 ? 'true' : 'false');
15✔
307
    response.setHeader(X_TEABLE_V2_FEATURE_HEADER, feature);
15✔
308
    response.setHeader(X_TEABLE_V2_REASON_HEADER, decision.reason);
15✔
309
  }
310

311
  protected async getPermissionContext(_baseId: string) {
312
    const permissions = this.cls.get('permissions');
36✔
313
    const permissionSet = new Set(permissions);
36✔
314
    const baseShare = this.cls.get('baseShare');
36✔
315
    return {
36✔
316
      permissionSet,
317
      shareNodeId: baseShare?.nodeId ?? undefined,
318
    };
319
  }
320
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc