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

teableio / teable / 20057330699

09 Dec 2025 08:46AM UTC coverage: 71.876% (+0.2%) from 71.698%
20057330699

Pull #2236

github

web-flow
Merge c436e10dc into b4cb680f5
Pull Request #2236: feat: space layout

22903 of 25549 branches covered (89.64%)

1958 of 2517 new or added lines in 38 files covered. (77.79%)

10 existing lines in 2 files now uncovered.

57778 of 80386 relevant lines covered (71.88%)

4257.53 hits per line

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

74.92
/apps/nestjs-backend/src/features/base-node/base-node.service.ts
1
/* eslint-disable sonarjs/no-duplicate-string */
5✔
2
import { Injectable, Logger } from '@nestjs/common';
3
import { generateBaseNodeId, HttpErrorCode } from '@teable/core';
4
import { PrismaService } from '@teable/db-main-prisma';
5
import type {
6
  IMoveBaseNodeRo,
7
  IBaseNodeVo,
8
  IBaseNodeTreeVo,
9
  ICreateBaseNodeRo,
10
  IDuplicateBaseNodeRo,
11
  IDuplicateTableRo,
12
  ICreateDashboardRo,
13
  ICreateFolderNodeRo,
14
  IDuplicateDashboardRo,
15
  IUpdateBaseNodeRo,
16
  IBaseNodeResourceMeta,
17
  IBaseNodeResourceMetaWithId,
18
  ICreateTableRo,
19
} from '@teable/openapi';
20
import { BaseNodeResourceType } from '@teable/openapi';
21
import { Knex } from 'knex';
22
import { isString, keyBy, omit } from 'lodash';
23
import { InjectModel } from 'nest-knexjs';
24
import { ClsService } from 'nestjs-cls';
25
import { CustomHttpException } from '../../custom.exception';
26
import { generateBaseNodeListCacheKey } from '../../performance-cache/generate-keys';
27
import { PerformanceCacheService } from '../../performance-cache/service';
28
import type { IPerformanceCacheStore } from '../../performance-cache/types';
29
import type { IClsStore } from '../../types/cls';
30
import { updateOrder } from '../../utils/update-order';
31
import { DashboardService } from '../dashboard/dashboard.service';
32
import { TableOpenApiService } from '../table/open-api/table-open-api.service';
33
import { prepareCreateTableRo } from '../table/open-api/table.pipe.helper';
34
import { TableDuplicateService } from '../table/table-duplicate.service';
35
import { BaseNodeListener } from './base-node.listener';
36
import { BaseNodeFolderService } from './folder/base-node-folder.service';
37
import { buildBatchUpdateSql } from './helper';
38

39
type IBaseNodeEntry = {
40
  id: string;
41
  baseId: string;
42
  parentId: string | null;
43
  resourceType: string;
44
  resourceId: string;
45
  order: number;
46
  children: { id: string; order: number }[];
47
  parent: { id: string } | null;
48
};
49

50
// max depth is maxFolderDepth + 1
5✔
51
const maxFolderDepth = 2;
5✔
52

53
@Injectable()
54
export class BaseNodeService {
5✔
55
  private readonly logger = new Logger(BaseNodeService.name);
123✔
56
  constructor(
123✔
57
    private readonly performanceCacheService: PerformanceCacheService<IPerformanceCacheStore>,
123✔
58
    private readonly prismaService: PrismaService,
123✔
59
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
123✔
60
    private readonly cls: ClsService<IClsStore>,
123✔
61
    private readonly baseNodeFolderService: BaseNodeFolderService,
123✔
62
    private readonly tableOpenApiService: TableOpenApiService,
123✔
63
    private readonly tableDuplicateService: TableDuplicateService,
123✔
64
    private readonly dashboardService: DashboardService,
123✔
65
    private readonly baseNodeListener: BaseNodeListener
123✔
66
  ) {}
123✔
67

68
  private get userId() {
123✔
69
    return this.cls.get('user.id');
120✔
70
  }
120✔
71

72
  private getSelect() {
123✔
73
    return {
156✔
74
      id: true,
156✔
75
      baseId: true,
156✔
76
      parentId: true,
156✔
77
      resourceType: true,
156✔
78
      resourceId: true,
156✔
79
      order: true,
156✔
80
      children: {
156✔
81
        select: { id: true, order: true },
156✔
82
        orderBy: { order: 'asc' as const },
156✔
83
      },
156✔
84
      parent: {
156✔
85
        select: { id: true },
156✔
86
      },
156✔
87
    };
156✔
88
  }
156✔
89

90
  private async entry2vo(
123✔
91
    entry: IBaseNodeEntry,
140✔
92
    resource?: IBaseNodeResourceMeta
140✔
93
  ): Promise<IBaseNodeVo> {
140✔
94
    if (resource) {
140✔
95
      return {
111✔
96
        ...entry,
111✔
97
        resourceType: entry.resourceType as BaseNodeResourceType,
111✔
98
        resourceMeta: resource,
111✔
99
      };
111✔
100
    }
111✔
101
    const { resourceType, resourceId } = entry;
29✔
102
    const list = await this.getNodeResource(entry.baseId, resourceType as BaseNodeResourceType, [
29✔
103
      resourceId,
29✔
104
    ]);
29✔
105
    const resourceMeta = list[0];
29✔
106
    return {
29✔
107
      ...entry,
29✔
108
      resourceType: resourceType as BaseNodeResourceType,
29✔
109
      resourceMeta: omit(resourceMeta, 'id'),
29✔
110
    };
29✔
111
  }
29✔
112

113
  protected getTableResources(baseId: string, ids?: string[]) {
123✔
114
    return this.prismaService.tableMeta.findMany({
33✔
115
      where: { baseId, id: { in: ids ? ids : undefined }, deletedTime: null },
33✔
116
      select: {
33✔
117
        id: true,
33✔
118
        name: true,
33✔
119
        icon: true,
33✔
120
      },
33✔
121
    });
33✔
122
  }
33✔
123

124
  protected getDashboardResources(baseId: string, ids?: string[]) {
123✔
125
    return this.prismaService.dashboard.findMany({
19✔
126
      where: { baseId, id: { in: ids ? ids : undefined } },
19✔
127
      select: {
19✔
128
        id: true,
19✔
129
        name: true,
19✔
130
      },
19✔
131
    });
19✔
132
  }
19✔
133

134
  protected getFolderResources(baseId: string, ids?: string[]) {
123✔
135
    return this.prismaService.baseNodeFolder.findMany({
25✔
136
      where: { baseId, id: { in: ids ? ids : undefined } },
25✔
137
      select: {
25✔
138
        id: true,
25✔
139
        name: true,
25✔
140
      },
25✔
141
    });
25✔
142
  }
25✔
143

144
  protected async getNodeResource(
123✔
145
    baseId: string,
77✔
146
    type: BaseNodeResourceType,
77✔
147
    ids?: string[]
77✔
148
  ): Promise<IBaseNodeResourceMetaWithId[]> {
77✔
149
    switch (type) {
77✔
150
      case BaseNodeResourceType.Folder:
77✔
151
        return this.getFolderResources(baseId, ids);
25✔
152
      case BaseNodeResourceType.Table:
77✔
153
        return this.getTableResources(baseId, ids);
33✔
154
      case BaseNodeResourceType.Dashboard:
77✔
155
        return this.getDashboardResources(baseId, ids);
19✔
156
      default:
77✔
NEW
157
        throw new CustomHttpException(
×
NEW
158
          `Invalid resource type ${type}`,
×
NEW
159
          HttpErrorCode.VALIDATION_ERROR,
×
NEW
160
          {
×
NEW
161
            localization: {
×
NEW
162
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
×
NEW
163
            },
×
NEW
164
          }
×
165
        );
166
    }
77✔
167
  }
77✔
168

169
  protected getResourceTypes(): BaseNodeResourceType[] {
123✔
170
    return [
16✔
171
      BaseNodeResourceType.Folder,
16✔
172
      BaseNodeResourceType.Table,
16✔
173
      BaseNodeResourceType.Dashboard,
16✔
174
    ];
16✔
175
  }
16✔
176

177
  async prepareNodeList(baseId: string): Promise<IBaseNodeVo[]> {
123✔
178
    const resourceTypes = this.getResourceTypes();
16✔
179
    const resourceResults = await Promise.all(
16✔
180
      resourceTypes.map((type) => this.getNodeResource(baseId, type))
16✔
181
    );
182

183
    const resources = resourceResults.flatMap((list, index) =>
16✔
184
      list.map((r) => ({ ...r, type: resourceTypes[index] }))
48✔
185
    );
186

187
    const resourceMap = keyBy(resources, (r) => `${r.type}_${r.id}`);
16✔
188
    const resourceKeys = new Set(resources.map((r) => `${r.type}_${r.id}`));
16✔
189

190
    const nodes = await this.prismaService.baseNode.findMany({
16✔
191
      where: { baseId },
16✔
192
      select: this.getSelect(),
16✔
193
      orderBy: { order: 'asc' },
16✔
194
    });
16✔
195

196
    const nodeKeys = new Set(nodes.map((n) => `${n.resourceType}_${n.resourceId}`));
16✔
197

198
    const toCreate = resources.filter((r) => !nodeKeys.has(`${r.type}_${r.id}`));
16✔
199
    const toDelete = nodes.filter((n) => !resourceKeys.has(`${n.resourceType}_${n.resourceId}`));
16✔
200
    const validParentIds = new Set(nodes.filter((n) => !toDelete.includes(n)).map((n) => n.id));
16✔
201
    const orphans = nodes.filter(
16✔
202
      (n) => n.parentId && !validParentIds.has(n.parentId) && !toDelete.includes(n)
16✔
203
    );
204

205
    if (toCreate.length === 0 && toDelete.length === 0 && orphans.length === 0) {
16✔
206
      return nodes.map((entry) => {
16✔
207
        const key = `${entry.resourceType}_${entry.resourceId}`;
64✔
208
        const resource = resourceMap[key];
64✔
209
        return {
64✔
210
          ...entry,
64✔
211
          resourceType: entry.resourceType as BaseNodeResourceType,
64✔
212
          resourceMeta: omit(resource, 'id'),
64✔
213
        };
64✔
214
      });
64✔
215
    }
16✔
216

NEW
217
    const finalMenus = await this.prismaService.$tx(async (prisma) => {
×
NEW
218
      // Delete redundant
×
NEW
219
      if (toDelete.length > 0) {
×
NEW
220
        await prisma.baseNode.deleteMany({
×
NEW
221
          where: { id: { in: toDelete.map((m) => m.id) } },
×
NEW
222
        });
×
NEW
223
      }
×
224

NEW
225
      // Prepare for create and update
×
NEW
226
      let nextOrder = 0;
×
NEW
227
      if (toCreate.length > 0 || orphans.length > 0) {
×
NEW
228
        const maxOrderAgg = await prisma.baseNode.aggregate({
×
NEW
229
          where: { baseId },
×
NEW
230
          _max: { order: true },
×
NEW
231
        });
×
NEW
232
        nextOrder = (maxOrderAgg._max.order ?? 0) + 1;
×
NEW
233
      }
×
234

NEW
235
      // Create missing
×
NEW
236
      if (toCreate.length > 0) {
×
NEW
237
        await prisma.baseNode.createMany({
×
NEW
238
          data: toCreate.map((r) => ({
×
NEW
239
            id: generateBaseNodeId(),
×
NEW
240
            baseId,
×
NEW
241
            resourceType: r.type,
×
NEW
242
            resourceId: r.id,
×
NEW
243
            order: nextOrder++,
×
NEW
244
            parentId: null,
×
NEW
245
            createdBy: this.userId,
×
NEW
246
          })),
×
NEW
247
        });
×
NEW
248
      }
×
249

NEW
250
      // Reset orphans to root level with new order
×
NEW
251
      if (orphans.length > 0) {
×
NEW
252
        await this.batchUpdateBaseNodes(
×
NEW
253
          orphans.map((orphan, index) => ({
×
NEW
254
            id: orphan.id,
×
NEW
255
            values: { parentId: null, order: nextOrder + index },
×
NEW
256
          }))
×
257
        );
NEW
258
      }
×
NEW
259
      return prisma.baseNode.findMany({
×
NEW
260
        where: { baseId },
×
NEW
261
        select: this.getSelect(),
×
NEW
262
        orderBy: { order: 'asc' },
×
NEW
263
      });
×
NEW
264
    });
×
265

NEW
266
    return await Promise.all(
×
NEW
267
      finalMenus.map(async (entry) => {
×
NEW
268
        const key = `${entry.resourceType}_${entry.resourceId}`;
×
NEW
269
        const resource = resourceMap[key];
×
NEW
270
        return await this.entry2vo(entry, omit(resource, 'id'));
×
NEW
271
      })
×
272
    );
NEW
273
  }
×
274

275
  async getNodeListWithCache(baseId: string): Promise<IBaseNodeVo[]> {
123✔
276
    return this.performanceCacheService.wrap(
16✔
277
      generateBaseNodeListCacheKey(baseId),
16✔
278
      () => this.prepareNodeList(baseId),
16✔
279
      {
16✔
280
        ttl: 60 * 60, // 1 hour
16✔
281
        statsType: 'base-node-list',
16✔
282
      }
16✔
283
    );
284
  }
16✔
285

286
  async getList(baseId: string): Promise<IBaseNodeVo[]> {
123✔
287
    return this.getNodeListWithCache(baseId);
3✔
288
  }
3✔
289

290
  async getTree(baseId: string): Promise<IBaseNodeTreeVo> {
123✔
291
    const nodes = await this.getNodeListWithCache(baseId);
13✔
292

293
    return {
13✔
294
      nodes,
13✔
295
      maxFolderDepth,
13✔
296
    };
13✔
297
  }
13✔
298

299
  async getNode(baseId: string, nodeId: string) {
123✔
300
    const node = await this.prismaService.baseNode
8✔
301
      .findFirstOrThrow({
8✔
302
        where: { baseId, id: nodeId },
8✔
303
        select: this.getSelect(),
8✔
304
      })
8✔
305
      .catch(() => {
8✔
NEW
306
        throw new CustomHttpException(`Base node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
307
          localization: {
×
NEW
308
            i18nKey: 'httpErrors.baseNode.notFound',
×
NEW
309
          },
×
NEW
310
        });
×
NEW
311
      });
×
312
    return {
8✔
313
      ...node,
8✔
314
      resourceType: node.resourceType as BaseNodeResourceType,
8✔
315
    };
8✔
316
  }
8✔
317

318
  async getNodeVo(baseId: string, nodeId: string): Promise<IBaseNodeVo> {
123✔
319
    const node = await this.getNode(baseId, nodeId);
8✔
320
    return this.entry2vo(node);
8✔
321
  }
8✔
322

323
  async create(baseId: string, ro: ICreateBaseNodeRo): Promise<IBaseNodeVo> {
123✔
324
    const { resourceType, parentId } = ro;
110✔
325

326
    const { entry, resource } = await this.prismaService.$tx(async (prisma) => {
110✔
327
      const resource = await this.createResource(baseId, ro);
110✔
328
      const resourceId = resource.id;
107✔
329

330
      const maxOrder = await this.getMaxOrder(baseId);
107✔
331
      const entry = await prisma.baseNode.create({
107✔
332
        data: {
107✔
333
          id: generateBaseNodeId(),
107✔
334
          baseId,
107✔
335
          resourceType,
107✔
336
          resourceId,
107✔
337
          order: maxOrder + 1,
107✔
338
          parentId,
107✔
339
          createdBy: this.userId,
107✔
340
        },
107✔
341
        select: this.getSelect(),
107✔
342
      });
107✔
343

344
      return {
107✔
345
        entry,
107✔
346
        resource,
107✔
347
      };
107✔
348
    });
107✔
349

350
    return this.entry2vo(entry, omit(resource, 'id'));
107✔
351
  }
107✔
352

353
  protected async createResource(
123✔
354
    baseId: string,
110✔
355
    createRo: ICreateBaseNodeRo
110✔
356
  ): Promise<IBaseNodeResourceMetaWithId> {
110✔
357
    const { resourceType, parentId, ...ro } = createRo;
110✔
358
    const parentNode = parentId ? await this.getParentNodeOrThrow(parentId) : null;
110✔
359
    if (parentNode && parentNode.resourceType !== BaseNodeResourceType.Folder) {
110✔
360
      throw new CustomHttpException('Parent must be a folder', HttpErrorCode.VALIDATION_ERROR, {
1✔
361
        localization: {
1✔
362
          i18nKey: 'httpErrors.baseNode.parentMustBeFolder',
1✔
363
        },
1✔
364
      });
1✔
365
    }
1✔
366

367
    if (parentNode && resourceType === BaseNodeResourceType.Folder) {
110✔
368
      await this.assertFolderDepth(baseId, parentNode.id);
21✔
369
    }
20✔
370

371
    switch (resourceType) {
107✔
372
      case BaseNodeResourceType.Folder: {
110✔
373
        const folder = await this.baseNodeFolderService.createFolder(
70✔
374
          baseId,
70✔
375
          ro as ICreateFolderNodeRo
70✔
376
        );
377
        return { id: folder.id, name: folder.name };
70✔
378
      }
70✔
379
      case BaseNodeResourceType.Table: {
110✔
380
        const preparedRo = prepareCreateTableRo(ro as ICreateTableRo);
27✔
381
        const table = await this.tableOpenApiService.createTable(baseId, preparedRo);
27✔
382

383
        return {
27✔
384
          id: table.id,
27✔
385
          name: table.name,
27✔
386
          icon: table.icon,
27✔
387
          defaultViewId: table.defaultViewId,
27✔
388
        };
27✔
389
      }
27✔
390
      case BaseNodeResourceType.Dashboard: {
110✔
391
        const dashboard = await this.dashboardService.createDashboard(
10✔
392
          baseId,
10✔
393
          ro as ICreateDashboardRo
10✔
394
        );
395
        return { id: dashboard.id, name: dashboard.name };
10✔
396
      }
10✔
397
      default:
110!
NEW
398
        throw new CustomHttpException(
×
NEW
399
          `Invalid resource type ${resourceType}`,
×
NEW
400
          HttpErrorCode.VALIDATION_ERROR,
×
NEW
401
          {
×
NEW
402
            localization: {
×
NEW
403
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
×
NEW
404
            },
×
NEW
405
          }
×
406
        );
407
    }
110✔
408
  }
110✔
409

410
  async duplicate(baseId: string, nodeId: string, ro: IDuplicateBaseNodeRo) {
123✔
411
    const anchor = await this.prismaService.baseNode
5✔
412
      .findFirstOrThrow({
5✔
413
        where: { baseId, id: nodeId },
5✔
414
      })
5✔
415
      .catch(() => {
5✔
NEW
416
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
417
          localization: {
×
NEW
418
            i18nKey: 'httpErrors.baseNode.notFound',
×
NEW
419
          },
×
NEW
420
        });
×
NEW
421
      });
×
422
    const { resourceType, resourceId } = anchor;
5✔
423

424
    if (resourceType === BaseNodeResourceType.Folder) {
5✔
425
      throw new CustomHttpException('Cannot duplicate folder', HttpErrorCode.VALIDATION_ERROR, {
1✔
426
        localization: {
1✔
427
          i18nKey: 'httpErrors.baseNode.cannotDuplicateFolder',
1✔
428
        },
1✔
429
      });
1✔
430
    }
1✔
431

432
    const { entry, resource } = await this.prismaService.$tx(async (prisma) => {
4✔
433
      const resource = await this.duplicateResource(
4✔
434
        baseId,
4✔
435
        resourceType as BaseNodeResourceType,
4✔
436
        resourceId,
4✔
437
        ro
4✔
438
      );
439

440
      const maxOrder = await this.getMaxOrder(baseId, anchor.parentId);
4✔
441
      const newNodeId = generateBaseNodeId();
4✔
442
      const entry = await prisma.baseNode.create({
4✔
443
        data: {
4✔
444
          id: newNodeId,
4✔
445
          baseId,
4✔
446
          resourceType,
4✔
447
          resourceId: resource.id,
4✔
448
          order: maxOrder + 1,
4✔
449
          parentId: anchor.parentId,
4✔
450
          createdBy: this.userId,
4✔
451
        },
4✔
452
        select: this.getSelect(),
4✔
453
      });
4✔
454

455
      await updateOrder({
4✔
456
        query: baseId,
4✔
457
        position: 'after',
4✔
458
        item: entry,
4✔
459
        anchorItem: anchor,
4✔
460
        getNextItem: async (whereOrder, align) => {
4✔
461
          return prisma.baseNode.findFirst({
4✔
462
            where: {
4✔
463
              baseId,
4✔
464
              parentId: anchor.parentId,
4✔
465
              order: whereOrder,
4✔
466
              id: { not: newNodeId },
4✔
467
            },
4✔
468
            select: { order: true, id: true },
4✔
469
            orderBy: { order: align },
4✔
470
          });
4✔
471
        },
4✔
472
        update: async (_, id, data) => {
4✔
473
          await prisma.baseNode.update({
4✔
474
            where: { id },
4✔
475
            data: { parentId: anchor.parentId, order: data.newOrder },
4✔
476
          });
4✔
477
        },
4✔
478
        shuffle: async () => {
4✔
NEW
479
          await this.shuffleOrders(baseId, anchor.parentId);
×
NEW
480
        },
×
481
      });
4✔
482

483
      return {
4✔
484
        entry,
4✔
485
        resource,
4✔
486
      };
4✔
487
    });
4✔
488

489
    return this.entry2vo(entry, omit(resource, 'id'));
4✔
490
  }
4✔
491

492
  protected async duplicateResource(
123✔
493
    baseId: string,
4✔
494
    type: BaseNodeResourceType,
4✔
495
    id: string,
4✔
496
    duplicateRo: IDuplicateBaseNodeRo
4✔
497
  ): Promise<IBaseNodeResourceMetaWithId> {
4✔
498
    switch (type) {
4✔
499
      case BaseNodeResourceType.Table: {
4✔
500
        const table = await this.tableDuplicateService.duplicateTable(
2✔
501
          baseId,
2✔
502
          id,
2✔
503
          duplicateRo as IDuplicateTableRo
2✔
504
        );
505

506
        return {
2✔
507
          id: table.id,
2✔
508
          name: table.name,
2✔
509
          icon: table.icon ?? undefined,
2✔
510
          defaultViewId: table.defaultViewId,
2✔
511
        };
2✔
512
      }
2✔
513
      case BaseNodeResourceType.Dashboard: {
4✔
514
        const dashboard = await this.dashboardService.duplicateDashboard(
2✔
515
          baseId,
2✔
516
          id,
2✔
517
          duplicateRo as IDuplicateDashboardRo
2✔
518
        );
519
        return { id: dashboard.id, name: dashboard.name };
2✔
520
      }
2✔
521
      default:
4!
NEW
522
        throw new CustomHttpException(
×
NEW
523
          `Invalid resource type ${type}`,
×
NEW
524
          HttpErrorCode.VALIDATION_ERROR,
×
NEW
525
          {
×
NEW
526
            localization: {
×
NEW
527
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
×
NEW
528
            },
×
NEW
529
          }
×
530
        );
531
    }
4✔
532
  }
4✔
533

534
  async update(baseId: string, nodeId: string, ro: IUpdateBaseNodeRo) {
123✔
535
    const node = await this.prismaService.baseNode
7✔
536
      .findFirstOrThrow({
7✔
537
        where: { baseId, id: nodeId },
7✔
538
        select: this.getSelect(),
7✔
539
      })
7✔
540
      .catch(() => {
7✔
NEW
541
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
542
          localization: {
×
NEW
543
            i18nKey: 'httpErrors.baseNode.notFound',
×
NEW
544
          },
×
NEW
545
        });
×
NEW
546
      });
×
547

548
    await this.prismaService.$tx(async () => {
7✔
549
      await this.updateResource(
7✔
550
        baseId,
7✔
551
        node.resourceType as BaseNodeResourceType,
7✔
552
        node.resourceId,
7✔
553
        ro
7✔
554
      );
555
    });
7✔
556

557
    return this.entry2vo(node);
7✔
558
  }
7✔
559

560
  protected async updateResource(
123✔
561
    baseId: string,
7✔
562
    type: BaseNodeResourceType,
7✔
563
    id: string,
7✔
564
    updateRo: IUpdateBaseNodeRo
7✔
565
  ): Promise<void> {
7✔
566
    const { name, icon } = updateRo;
7✔
567
    switch (type) {
7✔
568
      case BaseNodeResourceType.Folder:
7!
NEW
569
        if (name) {
×
NEW
570
          await this.baseNodeFolderService.renameFolder(baseId, id, { name });
×
NEW
571
        }
×
NEW
572
        break;
×
573
      case BaseNodeResourceType.Table:
7✔
574
        if (name) {
7✔
575
          await this.tableOpenApiService.updateName(baseId, id, name);
5✔
576
        }
5✔
577
        if (icon) {
7✔
578
          await this.tableOpenApiService.updateIcon(baseId, id, icon);
3✔
579
        }
3✔
580
        break;
7✔
581
      case BaseNodeResourceType.Dashboard:
7!
NEW
582
        if (name) {
×
NEW
583
          await this.dashboardService.renameDashboard(baseId, id, name);
×
NEW
584
        }
×
NEW
585
        break;
×
586
      default:
7!
NEW
587
        throw new CustomHttpException(
×
NEW
588
          `Invalid resource type ${type}`,
×
NEW
589
          HttpErrorCode.VALIDATION_ERROR,
×
NEW
590
          {
×
NEW
591
            localization: {
×
NEW
592
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
×
NEW
593
            },
×
NEW
594
          }
×
595
        );
596
    }
7✔
597
  }
7✔
598

599
  async delete(baseId: string, nodeId: string, permanent?: boolean) {
123✔
600
    const node = await this.prismaService.baseNode
99✔
601
      .findFirstOrThrow({
99✔
602
        where: { baseId, id: nodeId },
99✔
603
        select: { resourceType: true, resourceId: true },
99✔
604
      })
99✔
605
      .catch(() => {
99✔
NEW
606
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
607
          localization: {
×
NEW
608
            i18nKey: 'httpErrors.baseNode.notFound',
×
NEW
609
          },
×
NEW
610
        });
×
NEW
611
      });
×
612
    if (node.resourceType === BaseNodeResourceType.Folder) {
99✔
613
      const children = await this.prismaService.baseNode.findMany({
66✔
614
        where: { baseId, parentId: nodeId },
66✔
615
      });
66✔
616
      if (children.length > 0) {
66✔
617
        throw new CustomHttpException(
2✔
618
          'Cannot delete folder because it is not empty',
2✔
619
          HttpErrorCode.VALIDATION_ERROR,
2✔
620
          {
2✔
621
            localization: {
2✔
622
              i18nKey: 'httpErrors.baseNode.cannotDeleteEmptyFolder',
2✔
623
            },
2✔
624
          }
2✔
625
        );
626
      }
2✔
627
    }
66✔
628

629
    await this.prismaService.$tx(async (prisma) => {
97✔
630
      await this.deleteResource(
97✔
631
        baseId,
97✔
632
        node.resourceType as BaseNodeResourceType,
97✔
633
        node.resourceId,
97✔
634
        permanent
97✔
635
      );
636
      await prisma.baseNode.delete({
97✔
637
        where: { id: nodeId },
97✔
638
      });
97✔
639
    });
97✔
640

641
    return node;
97✔
642
  }
97✔
643

644
  protected async deleteResource(
123✔
645
    baseId: string,
97✔
646
    type: BaseNodeResourceType,
97✔
647
    id: string,
97✔
648
    permanent?: boolean
97✔
649
  ) {
97✔
650
    switch (type) {
97✔
651
      case BaseNodeResourceType.Folder:
97✔
652
        await this.baseNodeFolderService.deleteFolder(baseId, id);
64✔
653
        break;
64✔
654
      case BaseNodeResourceType.Table:
97✔
655
        if (permanent) {
25!
NEW
656
          await this.tableOpenApiService.permanentDeleteTables(baseId, [id]);
×
657
        } else {
25✔
658
          await this.tableOpenApiService.deleteTable(baseId, id);
25✔
659
        }
25✔
660
        break;
25✔
661
      case BaseNodeResourceType.Dashboard:
97✔
662
        await this.dashboardService.deleteDashboard(baseId, id);
8✔
663
        break;
8✔
664
      default:
97!
NEW
665
        throw new CustomHttpException(
×
NEW
666
          `Invalid resource type ${type}`,
×
NEW
667
          HttpErrorCode.VALIDATION_ERROR,
×
NEW
668
          {
×
NEW
669
            localization: {
×
NEW
670
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
×
NEW
671
            },
×
NEW
672
          }
×
673
        );
674
    }
97✔
675
  }
97✔
676

677
  async move(baseId: string, nodeId: string, ro: IMoveBaseNodeRo): Promise<IBaseNodeVo> {
123✔
678
    const { parentId, anchorId, position } = ro;
20✔
679

680
    const node = await this.prismaService.baseNode
20✔
681
      .findFirstOrThrow({
20✔
682
        where: { baseId, id: nodeId },
20✔
683
      })
20✔
684
      .catch(() => {
20✔
NEW
685
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
686
          localization: {
×
NEW
687
            i18nKey: 'httpErrors.baseNode.notFound',
×
NEW
688
          },
×
NEW
689
        });
×
NEW
690
      });
×
691

692
    if (isString(parentId) && isString(anchorId)) {
20!
NEW
693
      throw new CustomHttpException(
×
NEW
694
        'Only one of parentId or anchorId must be provided',
×
NEW
695
        HttpErrorCode.VALIDATION_ERROR,
×
NEW
696
        {
×
NEW
697
          localization: {
×
NEW
698
            i18nKey: 'httpErrors.baseNode.onlyOneOfParentIdOrAnchorIdRequired',
×
NEW
699
          },
×
NEW
700
        }
×
701
      );
NEW
702
    }
×
703

704
    if (parentId === nodeId) {
20!
705
      throw new CustomHttpException('Cannot move node to itself', HttpErrorCode.VALIDATION_ERROR, {
1✔
706
        localization: {
1✔
707
          i18nKey: 'httpErrors.baseNode.cannotMoveToItself',
1✔
708
        },
1✔
709
      });
1✔
710
    }
1✔
711

712
    if (anchorId === nodeId) {
20!
NEW
713
      throw new CustomHttpException(
×
NEW
714
        'Cannot move node to its own child (circular reference)',
×
NEW
715
        HttpErrorCode.VALIDATION_ERROR,
×
NEW
716
        {
×
NEW
717
          localization: {
×
NEW
718
            i18nKey: 'httpErrors.baseNode.cannotMoveToCircularReference',
×
NEW
719
          },
×
NEW
720
        }
×
721
      );
NEW
722
    }
✔
723

724
    let newNode: IBaseNodeEntry;
19✔
725
    if (anchorId) {
20!
726
      newNode = await this.moveNodeTo(baseId, node.id, { anchorId, position });
7✔
727
    } else if (parentId === null) {
20!
728
      newNode = await this.moveNodeToRoot(baseId, node.id);
2✔
729
    } else if (parentId) {
12✔
730
      newNode = await this.moveNodeToFolder(baseId, node.id, parentId);
10✔
731
    } else {
10!
NEW
732
      throw new CustomHttpException(
×
NEW
733
        'At least one of parentId or anchorId must be provided',
×
NEW
734
        HttpErrorCode.VALIDATION_ERROR,
×
NEW
735
        {
×
NEW
736
          localization: {
×
NEW
737
            i18nKey: 'httpErrors.baseNode.anchorIdOrParentIdRequired',
×
NEW
738
          },
×
NEW
739
        }
×
740
      );
NEW
741
    }
✔
742

743
    const vo = await this.entry2vo(newNode);
14✔
744
    this.baseNodeListener.presenceHandler(baseId, (presence) => {
14✔
745
      presence.submit({
14✔
746
        event: 'update',
14✔
747
        data: { ...vo },
14✔
748
      });
14✔
749
    });
14✔
750

751
    return vo;
14✔
752
  }
14✔
753

754
  private async moveNodeToRoot(baseId: string, nodeId: string) {
123✔
755
    return this.prismaService.$tx(async (prisma) => {
2✔
756
      const maxOrder = await this.getMaxOrder(baseId);
2✔
757
      return prisma.baseNode.update({
2✔
758
        where: { id: nodeId },
2✔
759
        select: this.getSelect(),
2✔
760
        data: {
2✔
761
          parentId: null,
2✔
762
          order: maxOrder + 1,
2✔
763
          lastModifiedBy: this.userId,
2✔
764
        },
2✔
765
      });
2✔
766
    });
2✔
767
  }
2✔
768

769
  private async moveNodeToFolder(baseId: string, nodeId: string, parentId: string) {
123✔
770
    return this.prismaService.$tx(async (prisma) => {
10✔
771
      const node = await prisma.baseNode
10✔
772
        .findFirstOrThrow({
10✔
773
          where: { baseId, id: nodeId },
10✔
774
        })
10✔
775
        .catch(() => {
10✔
NEW
776
          throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
777
            localization: {
×
NEW
778
              i18nKey: 'httpErrors.baseNode.notFound',
×
NEW
779
            },
×
NEW
780
          });
×
NEW
781
        });
×
782

783
      const parentNode = await prisma.baseNode
10✔
784
        .findFirstOrThrow({
10✔
785
          where: { baseId, id: parentId },
10✔
786
        })
10✔
787
        .catch(() => {
10✔
NEW
788
          throw new CustomHttpException(`Parent ${parentId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
789
            localization: {
×
NEW
790
              i18nKey: 'httpErrors.baseNode.parentNotFound',
×
NEW
791
            },
×
NEW
792
          });
×
NEW
793
        });
×
794

795
      if (parentNode.resourceType !== BaseNodeResourceType.Folder) {
10!
796
        throw new CustomHttpException(
1✔
797
          `Parent ${parentId} is not a folder`,
1✔
798
          HttpErrorCode.VALIDATION_ERROR,
1✔
799
          {
1✔
800
            localization: {
1✔
801
              i18nKey: 'httpErrors.baseNode.parentIsNotFolder',
1✔
802
            },
1✔
803
          }
1✔
804
        );
805
      }
1✔
806

807
      if (node.resourceType === BaseNodeResourceType.Folder && parentId) {
10!
808
        await this.assertFolderDepth(baseId, parentId);
3✔
809
      }
1✔
810

811
      // Check for circular reference
7✔
812
      const isCircular = await this.isCircularReference(baseId, nodeId, parentId);
7✔
813
      if (isCircular) {
10!
NEW
814
        throw new CustomHttpException(
×
NEW
815
          'Cannot move node to its own child (circular reference)',
×
NEW
816
          HttpErrorCode.VALIDATION_ERROR,
×
NEW
817
          {
×
NEW
818
            localization: {
×
NEW
819
              i18nKey: 'httpErrors.baseNode.circularReference',
×
NEW
820
            },
×
NEW
821
          }
×
822
        );
NEW
823
      }
✔
824

825
      const maxOrder = await this.getMaxOrder(baseId);
7✔
826
      return prisma.baseNode.update({
7✔
827
        where: { id: nodeId },
7✔
828
        select: this.getSelect(),
7✔
829
        data: {
7✔
830
          parentId,
7✔
831
          order: maxOrder + 1,
7✔
832
          lastModifiedBy: this.userId,
7✔
833
        },
7✔
834
      });
7✔
835
    });
7✔
836
  }
10✔
837

838
  private async moveNodeTo(
123✔
839
    baseId: string,
7✔
840
    nodeId: string,
7✔
841
    ro: Pick<IMoveBaseNodeRo, 'anchorId' | 'position'>
7✔
842
  ): Promise<IBaseNodeEntry> {
7✔
843
    const { anchorId, position } = ro;
7✔
844
    return this.prismaService.$tx(async (prisma) => {
7✔
845
      const node = await prisma.baseNode
7✔
846
        .findFirstOrThrow({
7✔
847
          where: { baseId, id: nodeId },
7✔
848
        })
7✔
849
        .catch(() => {
7✔
NEW
850
          throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
851
            localization: {
×
NEW
852
              i18nKey: 'httpErrors.baseNode.notFound',
×
NEW
853
            },
×
NEW
854
          });
×
NEW
855
        });
×
856

857
      const anchor = await prisma.baseNode
7✔
858
        .findFirstOrThrow({
7✔
859
          where: { baseId, id: anchorId },
7✔
860
        })
7✔
861
        .catch(() => {
7✔
862
          throw new CustomHttpException(`Anchor ${anchorId} not found`, HttpErrorCode.NOT_FOUND, {
1✔
863
            localization: {
1✔
864
              i18nKey: 'httpErrors.baseNode.anchorNotFound',
1✔
865
            },
1✔
866
          });
1✔
867
        });
1✔
868

869
      if (node.resourceType === BaseNodeResourceType.Folder && anchor.parentId) {
7✔
870
        await this.assertFolderDepth(baseId, anchor.parentId);
4✔
871
      }
3✔
872

873
      await updateOrder({
5✔
874
        query: baseId,
5✔
875
        position: position ?? 'after',
7!
876
        item: node,
7✔
877
        anchorItem: anchor,
7✔
878
        getNextItem: async (whereOrder, align) => {
7✔
879
          return prisma.baseNode.findFirst({
5✔
880
            where: {
5✔
881
              baseId,
5✔
882
              parentId: anchor.parentId,
5✔
883
              order: whereOrder,
5✔
884
            },
5✔
885
            select: { order: true, id: true },
5✔
886
            orderBy: { order: align },
5✔
887
          });
5✔
888
        },
5✔
889
        update: async (_, id, data) => {
7✔
890
          await prisma.baseNode.update({
5✔
891
            where: { id },
5✔
892
            data: { parentId: anchor.parentId, order: data.newOrder },
5✔
893
          });
5✔
894
        },
5✔
895
        shuffle: async () => {
7✔
NEW
896
          await this.shuffleOrders(baseId, anchor.parentId);
×
NEW
897
        },
×
898
      });
7✔
899

900
      return prisma.baseNode.findFirstOrThrow({
5✔
901
        where: { baseId, id: nodeId },
5✔
902
        select: this.getSelect(),
5✔
903
      });
5✔
904
    });
5✔
905
  }
7✔
906

907
  async getMaxOrder(baseId: string, parentId?: string | null) {
123✔
908
    const prisma = this.prismaService.txClient();
120✔
909
    const aggregate = await prisma.baseNode.aggregate({
120✔
910
      where: { baseId, parentId },
120✔
911
      _max: { order: true },
120✔
912
    });
120✔
913

914
    return aggregate._max.order ?? 0;
120✔
915
  }
120✔
916

917
  private async shuffleOrders(baseId: string, parentId: string | null) {
123✔
NEW
918
    const prisma = this.prismaService.txClient();
×
NEW
919
    const siblings = await prisma.baseNode.findMany({
×
NEW
920
      where: { baseId, parentId },
×
NEW
921
      orderBy: { order: 'asc' },
×
NEW
922
    });
×
923

NEW
924
    for (const [index, sibling] of siblings.entries()) {
×
NEW
925
      await prisma.baseNode.update({
×
NEW
926
        where: { id: sibling.id },
×
NEW
927
        data: { order: index + 10, lastModifiedBy: this.userId },
×
NEW
928
      });
×
NEW
929
    }
×
NEW
930
  }
×
931

932
  private async getParentNodeOrThrow(id: string) {
123✔
933
    const entry = await this.prismaService.baseNode.findFirst({
27✔
934
      where: { id },
27✔
935
      select: {
27✔
936
        id: true,
27✔
937
        parentId: true,
27✔
938
        resourceType: true,
27✔
939
        resourceId: true,
27✔
940
      },
27✔
941
    });
27✔
942
    if (!entry) {
27✔
943
      throw new CustomHttpException('Base node not found', HttpErrorCode.NOT_FOUND, {
1✔
944
        localization: {
1✔
945
          i18nKey: 'httpErrors.baseNode.notFound',
1✔
946
        },
1✔
947
      });
1✔
948
    }
1✔
949
    return entry;
26✔
950
  }
26✔
951

952
  private async assertFolderDepth(baseId: string, id: string) {
123✔
953
    const folderDepth = await this.getFolderDepth(baseId, id);
28✔
954
    if (folderDepth >= maxFolderDepth) {
28✔
955
      throw new CustomHttpException('Folder depth limit exceeded', HttpErrorCode.VALIDATION_ERROR, {
4✔
956
        localization: {
4✔
957
          i18nKey: 'httpErrors.baseNode.folderDepthLimitExceeded',
4✔
958
        },
4✔
959
      });
4✔
960
    }
4✔
961
  }
28✔
962

963
  private async getFolderDepth(baseId: string, id: string) {
123✔
964
    const prisma = this.prismaService.txClient();
28✔
965
    const allFolders = await prisma.baseNode.findMany({
28✔
966
      where: { baseId, resourceType: BaseNodeResourceType.Folder },
28✔
967
      select: { id: true, parentId: true },
28✔
968
    });
28✔
969

970
    let depth = 0;
28✔
971
    if (allFolders.length === 0) {
28!
NEW
972
      return depth;
×
NEW
973
    }
×
974

975
    const folderMap = keyBy(allFolders, 'id');
28✔
976
    let current = id;
28✔
977
    while (current) {
28✔
978
      depth++;
32✔
979
      const folder = folderMap[current];
32✔
980
      if (!folder) {
32!
NEW
981
        throw new CustomHttpException('Folder not found', HttpErrorCode.NOT_FOUND, {
×
NEW
982
          localization: {
×
NEW
983
            i18nKey: 'httpErrors.baseNode.folderNotFound',
×
NEW
984
          },
×
NEW
985
        });
×
NEW
986
      }
×
987
      if (folder.parentId === id) {
32!
NEW
988
        throw new CustomHttpException(
×
NEW
989
          'A folder cannot be its own parent',
×
NEW
990
          HttpErrorCode.VALIDATION_ERROR,
×
NEW
991
          {
×
NEW
992
            localization: {
×
NEW
993
              i18nKey: 'httpErrors.baseNode.circularReference',
×
NEW
994
            },
×
NEW
995
          }
×
996
        );
NEW
997
      }
×
998
      current = folder.parentId ?? '';
32✔
999
    }
32✔
1000
    return depth;
28✔
1001
  }
28✔
1002

1003
  private async isCircularReference(
123✔
1004
    baseId: string,
7✔
1005
    nodeId: string,
7✔
1006
    parentId: string
7✔
1007
  ): Promise<boolean> {
7✔
1008
    const knex = this.knex;
7✔
1009

1010
    // Non-recursive query: Start with the parent node
7✔
1011
    const nonRecursiveQuery = knex
7✔
1012
      .select('id', 'parent_id', 'base_id')
7✔
1013
      .from('base_node')
7✔
1014
      .where('id', parentId)
7✔
1015
      .andWhere('base_id', baseId);
7✔
1016

1017
    // Recursive query: Traverse up the parent chain
7✔
1018
    const recursiveQuery = knex
7✔
1019
      .select('bn.id', 'bn.parent_id', 'bn.base_id')
7✔
1020
      .from('base_node as bn')
7✔
1021
      .innerJoin('ancestors as a', function () {
7✔
1022
        // Join condition: bn.id = a.parent_id (get parent of current ancestor)
7✔
1023
        this.on('bn.id', '=', 'a.parent_id').andOn('bn.base_id', '=', knex.raw('?', [baseId]));
7✔
1024
      });
7✔
1025

1026
    // Combine non-recursive and recursive queries
7✔
1027
    const cteQuery = nonRecursiveQuery.union(recursiveQuery);
7✔
1028

1029
    // Build final query with recursive CTE
7✔
1030
    const finalQuery = knex
7✔
1031
      .withRecursive('ancestors', ['id', 'parent_id', 'base_id'], cteQuery)
7✔
1032
      .select('id')
7✔
1033
      .from('ancestors')
7✔
1034
      .where('id', nodeId)
7✔
1035
      .limit(1)
7✔
1036
      .toQuery();
7✔
1037

1038
    // Execute query
7✔
1039
    const result = await this.prismaService
7✔
1040
      .txClient()
7✔
1041
      .$queryRawUnsafe<Array<{ id: string }>>(finalQuery);
7✔
1042

1043
    return result.length > 0;
7✔
1044
  }
7✔
1045

1046
  async batchUpdateBaseNodes(data: { id: string; values: { [key: string]: unknown } }[]) {
123✔
NEW
1047
    const sql = buildBatchUpdateSql(this.knex, data);
×
NEW
1048
    if (!sql) {
×
NEW
1049
      return;
×
NEW
1050
    }
×
NEW
1051
    await this.prismaService.txClient().$executeRawUnsafe(sql);
×
NEW
1052
  }
×
1053
}
123✔
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