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

teableio / teable / 21931418853

12 Feb 2026 02:38AM UTC coverage: 64.11% (+0.02%) from 64.087%
21931418853

Pull #2595

github

web-flow
Merge cd2dbff7b into 367739c6d
Pull Request #2595: [sync] feat: support base share (node) T1873 T1122 T730 T682 (#1122)

5067 of 6630 branches covered (76.43%)

385 of 582 new or added lines in 30 files covered. (66.15%)

15 existing lines in 2 files now uncovered.

23274 of 36303 relevant lines covered (64.11%)

9079.48 hits per line

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

83.51
/apps/nestjs-backend/src/features/base-node/base-node.service.ts
1
/* eslint-disable sonarjs/no-duplicate-string */
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
  IBaseNodePresenceCreatePayload,
20
  IBaseNodePresenceDeletePayload,
21
  IBaseNodePresenceUpdatePayload,
22
  IBaseNodeTableResourceMeta,
23
} from '@teable/openapi';
24
import { BaseNodeResourceType } from '@teable/openapi';
25
import { Knex } from 'knex';
26
import { isString, keyBy, omit } from 'lodash';
27
import { InjectModel } from 'nest-knexjs';
28
import { ClsService } from 'nestjs-cls';
29
import type { LocalPresence } from 'sharedb/lib/client';
30
import { CustomHttpException } from '../../custom.exception';
31
import {
32
  generateBaseNodeListCacheKey,
33
  generateBaseShareListCacheKey,
34
} from '../../performance-cache/generate-keys';
35
import { PerformanceCacheService } from '../../performance-cache/service';
36
import type { IPerformanceCacheStore } from '../../performance-cache/types';
37
import { ShareDbService } from '../../share-db/share-db.service';
38
import type { IClsStore } from '../../types/cls';
39
import { updateOrder } from '../../utils/update-order';
40
import { DashboardService } from '../dashboard/dashboard.service';
41
import { TableOpenApiService } from '../table/open-api/table-open-api.service';
42
import { prepareCreateTableRo } from '../table/open-api/table.pipe.helper';
43
import { TableDuplicateService } from '../table/table-duplicate.service';
44
import { BaseNodeFolderService } from './folder/base-node-folder.service';
45
import { buildBatchUpdateSql, presenceHandler } from './helper';
46

47
type IBaseNodeEntry = {
48
  id: string;
49
  baseId: string;
50
  parentId: string | null;
51
  resourceType: string;
52
  resourceId: string;
53
  order: number;
54
  children: { id: string; order: number }[];
55
  parent: { id: string } | null;
56
};
57

58
// max depth is maxFolderDepth + 1
59
const maxFolderDepth = 2;
280✔
60

61
@Injectable()
62
export class BaseNodeService {
63
  private readonly logger = new Logger(BaseNodeService.name);
292✔
64
  constructor(
65
    private readonly performanceCacheService: PerformanceCacheService<IPerformanceCacheStore>,
292✔
66
    private readonly shareDbService: ShareDbService,
292✔
67
    private readonly prismaService: PrismaService,
292✔
68
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
292✔
69
    private readonly cls: ClsService<IClsStore & { ignoreBaseNodeListener?: boolean }>,
292✔
70
    private readonly baseNodeFolderService: BaseNodeFolderService,
292✔
71
    private readonly tableOpenApiService: TableOpenApiService,
292✔
72
    private readonly tableDuplicateService: TableDuplicateService,
292✔
73
    private readonly dashboardService: DashboardService
292✔
74
  ) {}
75

76
  private get userId() {
77
    return this.cls.get('user.id');
336✔
78
  }
79

80
  private setIgnoreBaseNodeListener() {
81
    this.cls.set('ignoreBaseNodeListener', true);
524✔
82
  }
83

84
  /**
85
   * Delete all share records for a node and invalidate cache
86
   */
87
  private async deleteNodeShares(baseId: string, nodeId: string): Promise<void> {
88
    const deleted = await this.prismaService.baseShare.deleteMany({
194✔
89
      where: { baseId, nodeId },
90
    });
91

92
    // Invalidate cache if any shares were deleted
93
    if (deleted.count > 0) {
194✔
NEW
94
      await this.performanceCacheService.del(generateBaseShareListCacheKey(baseId));
×
95
    }
96
  }
97

98
  private getSelect() {
99
    return {
406✔
100
      id: true,
101
      baseId: true,
102
      parentId: true,
103
      resourceType: true,
104
      resourceId: true,
105
      order: true,
106
      children: {
107
        select: { id: true, order: true },
108
        orderBy: { order: 'asc' as const },
109
      },
110
      parent: {
111
        select: { id: true },
112
      },
113
    };
114
  }
115

116
  private generateDefaultUrl(
117
    baseId: string,
118
    resourceType: BaseNodeResourceType,
119
    resourceId: string,
120
    resourceMeta?: IBaseNodeResourceMeta
121
  ): string {
122
    switch (resourceType) {
542✔
123
      case BaseNodeResourceType.Table: {
124
        const tableMeta = resourceMeta as IBaseNodeTableResourceMeta | undefined;
230✔
125
        const viewId = tableMeta?.defaultViewId;
230✔
126
        if (viewId) {
230✔
127
          return `/base/${baseId}/table/${resourceId}/${viewId}`;
68✔
128
        }
129
        return `/base/${baseId}/table/${resourceId}`;
162✔
130
      }
131
      case BaseNodeResourceType.Dashboard:
132
        return `/base/${baseId}/dashboard/${resourceId}`;
60✔
133
      case BaseNodeResourceType.Workflow:
134
        return `/base/${baseId}/automation/${resourceId}`;
×
135
      case BaseNodeResourceType.App:
136
        return `/base/${baseId}/app/${resourceId}`;
×
137
      case BaseNodeResourceType.Folder:
138
        return `/base/${baseId}`;
252✔
139
      default:
140
        return `/base/${baseId}`;
×
141
    }
142
  }
143

144
  private async entry2vo(
145
    entry: IBaseNodeEntry,
146
    resource?: IBaseNodeResourceMeta
147
  ): Promise<IBaseNodeVo> {
148
    const resourceMeta =
149
      resource ||
378✔
150
      (
151
        await this.getNodeResource(entry.baseId, entry.resourceType as BaseNodeResourceType, [
152
          entry.resourceId,
153
        ])
154
      )[0];
155
    const resourceMetaWithoutId = resource ? resource : omit(resourceMeta, 'id');
378✔
156

157
    const defaultUrl = this.generateDefaultUrl(
378✔
158
      entry.baseId,
159
      entry.resourceType as BaseNodeResourceType,
160
      entry.resourceId,
161
      resourceMetaWithoutId
162
    );
163

164
    return {
378✔
165
      ...entry,
166
      resourceType: entry.resourceType as BaseNodeResourceType,
167
      resourceMeta: resourceMetaWithoutId,
168
      defaultUrl,
169
    };
170
  }
171

172
  protected getTableResources(baseId: string, ids?: string[]) {
173
    return this.prismaService.tableMeta.findMany({
116✔
174
      where: { baseId, id: { in: ids ? ids : undefined }, deletedTime: null },
175
      select: {
176
        id: true,
177
        name: true,
178
        icon: true,
179
      },
180
    });
181
  }
182

183
  protected getDashboardResources(baseId: string, ids?: string[]) {
184
    return this.prismaService.dashboard.findMany({
70✔
185
      where: { baseId, id: { in: ids ? ids : undefined } },
186
      select: {
187
        id: true,
188
        name: true,
189
      },
190
    });
191
  }
192

193
  protected getFolderResources(baseId: string, ids?: string[]) {
194
    return this.prismaService.baseNodeFolder.findMany({
82✔
195
      where: { baseId, id: { in: ids ? ids : undefined } },
196
      select: {
197
        id: true,
198
        name: true,
199
      },
200
    });
201
  }
202

203
  protected async getNodeResource(
204
    baseId: string,
205
    type: BaseNodeResourceType,
206
    ids?: string[]
207
  ): Promise<IBaseNodeResourceMetaWithId[]> {
208
    switch (type) {
268✔
209
      case BaseNodeResourceType.Folder:
210
        return this.getFolderResources(baseId, ids);
82✔
211
      case BaseNodeResourceType.Table:
212
        return this.getTableResources(baseId, ids);
116✔
213
      case BaseNodeResourceType.Dashboard:
214
        return this.getDashboardResources(baseId, ids);
70✔
215
      default:
216
        throw new CustomHttpException(
×
217
          `Invalid resource type ${type}`,
218
          HttpErrorCode.VALIDATION_ERROR,
219
          {
220
            localization: {
221
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
222
            },
223
          }
224
        );
225
    }
226
  }
227

228
  protected getResourceTypes(): BaseNodeResourceType[] {
229
    return [
64✔
230
      BaseNodeResourceType.Folder,
231
      BaseNodeResourceType.Table,
232
      BaseNodeResourceType.Dashboard,
233
    ];
234
  }
235

236
  async prepareNodeList(baseId: string): Promise<IBaseNodeVo[]> {
237
    const resourceTypes = this.getResourceTypes();
64✔
238
    const resourceResults = await Promise.all(
64✔
239
      resourceTypes.map((type) => this.getNodeResource(baseId, type))
192✔
240
    );
241

242
    const resources = resourceResults.flatMap((list, index) =>
64✔
243
      list.map((r) => ({ ...r, type: resourceTypes[index] }))
220✔
244
    );
245

246
    const resourceMap = keyBy(resources, (r) => `${r.type}_${r.id}`);
220✔
247
    const resourceKeys = new Set(resources.map((r) => `${r.type}_${r.id}`));
220✔
248

249
    const nodes = await this.prismaService.baseNode.findMany({
64✔
250
      where: { baseId },
251
      select: this.getSelect(),
252
      orderBy: { order: 'asc' },
253
    });
254

255
    const nodeKeys = new Set(nodes.map((n) => `${n.resourceType}_${n.resourceId}`));
166✔
256

257
    const toCreate = resources.filter((r) => !nodeKeys.has(`${r.type}_${r.id}`));
220✔
258
    const toDelete = nodes.filter((n) => !resourceKeys.has(`${n.resourceType}_${n.resourceId}`));
166✔
259
    const validParentIds = new Set(nodes.filter((n) => !toDelete.includes(n)).map((n) => n.id));
166✔
260
    const orphans = nodes.filter(
64✔
261
      (n) => n.parentId && !validParentIds.has(n.parentId) && !toDelete.includes(n)
166✔
262
    );
263

264
    if (toCreate.length === 0 && toDelete.length === 0 && orphans.length === 0) {
64✔
265
      return nodes.map((entry) => {
44✔
266
        const key = `${entry.resourceType}_${entry.resourceId}`;
164✔
267
        const resource = resourceMap[key];
164✔
268
        const resourceMeta = omit(resource, 'id');
164✔
269
        const defaultUrl = this.generateDefaultUrl(
164✔
270
          baseId,
271
          entry.resourceType as BaseNodeResourceType,
272
          entry.resourceId,
273
          resourceMeta
274
        );
275
        return {
164✔
276
          ...entry,
277
          resourceType: entry.resourceType as BaseNodeResourceType,
278
          resourceMeta,
279
          defaultUrl,
280
        };
281
      });
282
    }
283

284
    const finalMenus = await this.prismaService.$tx(async (prisma) => {
20✔
285
      // Delete redundant
286
      if (toDelete.length > 0) {
20✔
287
        await prisma.baseNode.deleteMany({
×
288
          where: { id: { in: toDelete.map((m) => m.id) } },
×
289
        });
290
      }
291

292
      // Prepare for create and update
293
      let nextOrder = 0;
20✔
294
      if (toCreate.length > 0 || orphans.length > 0) {
20✔
295
        const maxOrderAgg = await prisma.baseNode.aggregate({
20✔
296
          where: { baseId },
297
          _max: { order: true },
298
        });
299
        nextOrder = (maxOrderAgg._max.order ?? 0) + 1;
20✔
300
      }
301

302
      // Create missing
303
      if (toCreate.length > 0) {
20✔
304
        await prisma.baseNode.createMany({
20✔
305
          data: toCreate.map((r) => ({
54✔
306
            id: generateBaseNodeId(),
307
            baseId,
308
            resourceType: r.type,
309
            resourceId: r.id,
310
            order: nextOrder++,
311
            parentId: null,
312
            createdBy: this.userId,
313
          })),
314
        });
315
      }
316

317
      // Reset orphans to root level with new order
318
      if (orphans.length > 0) {
20✔
319
        await this.batchUpdateBaseNodes(
×
320
          orphans.map((orphan, index) => ({
×
321
            id: orphan.id,
322
            values: { parentId: null, order: nextOrder + index },
323
          }))
324
        );
325
      }
326
      return prisma.baseNode.findMany({
20✔
327
        where: { baseId },
328
        select: this.getSelect(),
329
        orderBy: { order: 'asc' },
330
      });
331
    });
332

333
    return await Promise.all(
20✔
334
      finalMenus.map(async (entry) => {
335
        const key = `${entry.resourceType}_${entry.resourceId}`;
56✔
336
        const resource = resourceMap[key];
56✔
337
        return await this.entry2vo(entry, omit(resource, 'id'));
56✔
338
      })
339
    );
340
  }
341

342
  async getNodeListWithCache(baseId: string): Promise<IBaseNodeVo[]> {
343
    return this.performanceCacheService.wrap(
64✔
344
      generateBaseNodeListCacheKey(baseId),
345
      () => this.prepareNodeList(baseId),
64✔
346
      {
347
        ttl: 60 * 60, // 1 hour
348
        statsType: 'base-node-list',
349
      }
350
    );
351
  }
352

353
  async getList(baseId: string): Promise<IBaseNodeVo[]> {
354
    return this.getNodeListWithCache(baseId);
20✔
355
  }
356

357
  async getTree(baseId: string): Promise<IBaseNodeTreeVo> {
358
    const nodes = await this.getNodeListWithCache(baseId);
44✔
359

360
    return {
44✔
361
      nodes,
362
      maxFolderDepth,
363
    };
364
  }
365

366
  async getNode(baseId: string, nodeId: string) {
367
    const node = await this.prismaService.baseNode
16✔
368
      .findFirstOrThrow({
369
        where: { baseId, id: nodeId },
370
        select: this.getSelect(),
371
      })
372
      .catch(() => {
373
        throw new CustomHttpException(`Base node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
374
          localization: {
375
            i18nKey: 'httpErrors.baseNode.notFound',
376
          },
377
        });
378
      });
379
    return {
16✔
380
      ...node,
381
      resourceType: node.resourceType as BaseNodeResourceType,
382
    };
383
  }
384

385
  async getNodeVo(baseId: string, nodeId: string): Promise<IBaseNodeVo> {
386
    const node = await this.getNode(baseId, nodeId);
16✔
387
    return this.entry2vo(node);
16✔
388
  }
389

390
  async create(baseId: string, ro: ICreateBaseNodeRo): Promise<IBaseNodeVo> {
391
    this.setIgnoreBaseNodeListener();
244✔
392

393
    const { resourceType, parentId } = ro;
244✔
394
    const resource = await this.createResource(baseId, ro);
244✔
395
    const resourceId = resource.id;
238✔
396

397
    const maxOrder = await this.getMaxOrder(baseId);
238✔
398
    const entry = await this.prismaService.baseNode.create({
238✔
399
      data: {
400
        id: generateBaseNodeId(),
401
        baseId,
402
        resourceType,
403
        resourceId,
404
        order: maxOrder + 1,
405
        parentId,
406
        createdBy: this.userId,
407
      },
408
      select: this.getSelect(),
409
    });
410

411
    const vo = await this.entry2vo(entry, omit(resource, 'id'));
238✔
412
    this.presenceHandler(baseId, (presence) => {
238✔
413
      presence.submit({
238✔
414
        event: 'create',
415
        data: { ...vo },
416
      });
417
    });
418

419
    return vo;
238✔
420
  }
421

422
  protected async createResource(
423
    baseId: string,
424
    createRo: ICreateBaseNodeRo
425
  ): Promise<IBaseNodeResourceMetaWithId> {
426
    const { resourceType, parentId, ...ro } = createRo;
244✔
427
    const parentNode = parentId ? await this.getParentNodeOrThrow(parentId) : null;
244✔
428
    if (parentNode && parentNode.resourceType !== BaseNodeResourceType.Folder) {
244✔
429
      throw new CustomHttpException('Parent must be a folder', HttpErrorCode.VALIDATION_ERROR, {
2✔
430
        localization: {
431
          i18nKey: 'httpErrors.baseNode.parentMustBeFolder',
432
        },
433
      });
434
    }
435

436
    if (parentNode && resourceType === BaseNodeResourceType.Folder) {
240✔
437
      await this.assertFolderDepth(baseId, parentNode.id);
42✔
438
    }
439

440
    switch (resourceType) {
238✔
441
      case BaseNodeResourceType.Folder: {
442
        const folder = await this.baseNodeFolderService.createFolder(
154✔
443
          baseId,
444
          ro as ICreateFolderNodeRo
445
        );
446
        return { id: folder.id, name: folder.name };
154✔
447
      }
448
      case BaseNodeResourceType.Table: {
449
        const preparedRo = prepareCreateTableRo(ro as ICreateTableRo);
64✔
450
        const table = await this.tableOpenApiService.createTable(baseId, preparedRo);
64✔
451

452
        return {
64✔
453
          id: table.id,
454
          name: table.name,
455
          icon: table.icon,
456
          defaultViewId: table.defaultViewId,
457
        };
458
      }
459
      case BaseNodeResourceType.Dashboard: {
460
        const dashboard = await this.dashboardService.createDashboard(
20✔
461
          baseId,
462
          ro as ICreateDashboardRo
463
        );
464
        return { id: dashboard.id, name: dashboard.name };
20✔
465
      }
466
      default:
467
        throw new CustomHttpException(
×
468
          `Invalid resource type ${resourceType}`,
469
          HttpErrorCode.VALIDATION_ERROR,
470
          {
471
            localization: {
472
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
473
            },
474
          }
475
        );
476
    }
477
  }
478

479
  async duplicate(baseId: string, nodeId: string, ro: IDuplicateBaseNodeRo) {
480
    this.setIgnoreBaseNodeListener();
10✔
481

482
    const anchor = await this.prismaService.baseNode
10✔
483
      .findFirstOrThrow({
484
        where: { baseId, id: nodeId },
485
      })
486
      .catch(() => {
487
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
488
          localization: {
489
            i18nKey: 'httpErrors.baseNode.notFound',
490
          },
491
        });
492
      });
493
    const { resourceType, resourceId } = anchor;
10✔
494

495
    if (resourceType === BaseNodeResourceType.Folder) {
10✔
496
      throw new CustomHttpException('Cannot duplicate folder', HttpErrorCode.VALIDATION_ERROR, {
2✔
497
        localization: {
498
          i18nKey: 'httpErrors.baseNode.cannotDuplicateFolder',
499
        },
500
      });
501
    }
502

503
    const resource = await this.duplicateResource(
8✔
504
      baseId,
505
      resourceType as BaseNodeResourceType,
506
      resourceId,
507
      ro
508
    );
509
    const { entry } = await this.prismaService.$tx(async (prisma) => {
8✔
510
      const maxOrder = await this.getMaxOrder(baseId, anchor.parentId);
8✔
511
      const newNodeId = generateBaseNodeId();
8✔
512
      const entry = await prisma.baseNode.create({
8✔
513
        data: {
514
          id: newNodeId,
515
          baseId,
516
          resourceType,
517
          resourceId: resource.id,
518
          order: maxOrder + 1,
519
          parentId: anchor.parentId,
520
          createdBy: this.userId,
521
        },
522
        select: this.getSelect(),
523
      });
524

525
      await updateOrder({
8✔
526
        query: baseId,
527
        position: 'after',
528
        item: entry,
529
        anchorItem: anchor,
530
        getNextItem: async (whereOrder, align) => {
531
          return prisma.baseNode.findFirst({
8✔
532
            where: {
533
              baseId,
534
              parentId: anchor.parentId,
535
              order: whereOrder,
536
              id: { not: newNodeId },
537
            },
538
            select: { order: true, id: true },
539
            orderBy: { order: align },
540
          });
541
        },
542
        update: async (_, id, data) => {
543
          await prisma.baseNode.update({
8✔
544
            where: { id },
545
            data: { parentId: anchor.parentId, order: data.newOrder },
546
          });
547
        },
548
        shuffle: async () => {
549
          await this.shuffleOrders(baseId, anchor.parentId);
×
550
        },
551
      });
552

553
      return {
8✔
554
        entry,
555
      };
556
    });
557

558
    const vo = await this.entry2vo(entry, omit(resource, 'id'));
8✔
559
    this.presenceHandler(baseId, (presence) => {
8✔
560
      presence.submit({
8✔
561
        event: 'create',
562
        data: { ...vo },
563
      });
564
    });
565
    return vo;
8✔
566
  }
567

568
  protected async duplicateResource(
569
    baseId: string,
570
    type: BaseNodeResourceType,
571
    id: string,
572
    duplicateRo: IDuplicateBaseNodeRo
573
  ): Promise<IBaseNodeResourceMetaWithId> {
574
    switch (type) {
8✔
575
      case BaseNodeResourceType.Table: {
576
        const table = await this.tableDuplicateService.duplicateTable(
4✔
577
          baseId,
578
          id,
579
          duplicateRo as IDuplicateTableRo
580
        );
581

582
        return {
4✔
583
          id: table.id,
584
          name: table.name,
585
          icon: table.icon ?? undefined,
586
          defaultViewId: table.defaultViewId,
587
        };
588
      }
589
      case BaseNodeResourceType.Dashboard: {
590
        const dashboard = await this.dashboardService.duplicateDashboard(
4✔
591
          baseId,
592
          id,
593
          duplicateRo as IDuplicateDashboardRo
594
        );
595
        return { id: dashboard.id, name: dashboard.name };
4✔
596
      }
597
      default:
598
        throw new CustomHttpException(
×
599
          `Invalid resource type ${type}`,
600
          HttpErrorCode.VALIDATION_ERROR,
601
          {
602
            localization: {
603
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
604
            },
605
          }
606
        );
607
    }
608
  }
609

610
  async update(baseId: string, nodeId: string, ro: IUpdateBaseNodeRo) {
611
    this.setIgnoreBaseNodeListener();
14✔
612

613
    const node = await this.prismaService.baseNode
14✔
614
      .findFirstOrThrow({
615
        where: { baseId, id: nodeId },
616
        select: this.getSelect(),
617
      })
618
      .catch(() => {
619
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
620
          localization: {
621
            i18nKey: 'httpErrors.baseNode.notFound',
622
          },
623
        });
624
      });
625

626
    await this.updateResource(
14✔
627
      baseId,
628
      node.resourceType as BaseNodeResourceType,
629
      node.resourceId,
630
      ro
631
    );
632

633
    const vo = await this.entry2vo(node);
14✔
634
    this.presenceHandler(baseId, (presence) => {
14✔
635
      presence.submit({
14✔
636
        event: 'update',
637
        data: { ...vo },
638
      });
639
    });
640
    return vo;
14✔
641
  }
642

643
  protected async updateResource(
644
    baseId: string,
645
    type: BaseNodeResourceType,
646
    id: string,
647
    updateRo: IUpdateBaseNodeRo
648
  ): Promise<void> {
649
    const { name, icon } = updateRo;
14✔
650
    switch (type) {
14✔
651
      case BaseNodeResourceType.Folder:
652
        if (name) {
×
653
          await this.baseNodeFolderService.renameFolder(baseId, id, { name });
×
654
        }
655
        break;
×
656
      case BaseNodeResourceType.Table:
657
        if (name) {
14✔
658
          await this.tableOpenApiService.updateName(baseId, id, name);
10✔
659
        }
660
        if (icon) {
14✔
661
          await this.tableOpenApiService.updateIcon(baseId, id, icon);
6✔
662
        }
663
        break;
14✔
664
      case BaseNodeResourceType.Dashboard:
665
        if (name) {
×
666
          await this.dashboardService.renameDashboard(baseId, id, name);
×
667
        }
668
        break;
×
669
      default:
670
        throw new CustomHttpException(
×
671
          `Invalid resource type ${type}`,
672
          HttpErrorCode.VALIDATION_ERROR,
673
          {
674
            localization: {
675
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
676
            },
677
          }
678
        );
679
    }
680
  }
681

682
  async delete(baseId: string, nodeId: string, permanent?: boolean) {
683
    this.setIgnoreBaseNodeListener();
198✔
684

685
    const node = await this.prismaService.baseNode
198✔
686
      .findFirstOrThrow({
687
        where: { baseId, id: nodeId },
688
        select: { resourceType: true, resourceId: true },
689
      })
690
      .catch(() => {
691
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
692
          localization: {
693
            i18nKey: 'httpErrors.baseNode.notFound',
694
          },
695
        });
696
      });
697
    if (node.resourceType === BaseNodeResourceType.Folder) {
198✔
698
      const children = await this.prismaService.baseNode.findMany({
132✔
699
        where: { baseId, parentId: nodeId },
700
      });
701
      if (children.length > 0) {
132✔
702
        throw new CustomHttpException(
4✔
703
          'Cannot delete folder because it is not empty',
704
          HttpErrorCode.VALIDATION_ERROR,
705
          {
706
            localization: {
707
              i18nKey: 'httpErrors.baseNode.cannotDeleteEmptyFolder',
708
            },
709
          }
710
        );
711
      }
712
    }
713

714
    // Clean up share records for this node before deletion
715
    await this.deleteNodeShares(baseId, nodeId);
194✔
716

717
    await this.deleteResource(
194✔
718
      baseId,
719
      node.resourceType as BaseNodeResourceType,
720
      node.resourceId,
721
      permanent
722
    );
723
    await this.prismaService.baseNode.delete({
194✔
724
      where: { id: nodeId },
725
    });
726

727
    this.presenceHandler(baseId, (presence) => {
194✔
728
      presence.submit({
194✔
729
        event: 'delete',
730
        data: { id: nodeId },
731
      });
732
    });
733
    return node;
194✔
734
  }
735

736
  protected async deleteResource(
737
    baseId: string,
738
    type: BaseNodeResourceType,
739
    id: string,
740
    permanent?: boolean
741
  ) {
742
    switch (type) {
194✔
743
      case BaseNodeResourceType.Folder:
744
        await this.baseNodeFolderService.deleteFolder(baseId, id);
128✔
745
        break;
128✔
746
      case BaseNodeResourceType.Table:
747
        if (permanent) {
50✔
748
          await this.tableOpenApiService.permanentDeleteTables(baseId, [id]);
×
749
        } else {
750
          await this.tableOpenApiService.deleteTable(baseId, id);
50✔
751
        }
752
        break;
50✔
753
      case BaseNodeResourceType.Dashboard:
754
        await this.dashboardService.deleteDashboard(baseId, id);
16✔
755
        break;
16✔
756
      default:
757
        throw new CustomHttpException(
×
758
          `Invalid resource type ${type}`,
759
          HttpErrorCode.VALIDATION_ERROR,
760
          {
761
            localization: {
762
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
763
            },
764
          }
765
        );
766
    }
767
  }
768

769
  async move(baseId: string, nodeId: string, ro: IMoveBaseNodeRo): Promise<IBaseNodeVo> {
770
    this.setIgnoreBaseNodeListener();
58✔
771

772
    const { parentId, anchorId, position } = ro;
58✔
773

774
    const node = await this.prismaService.baseNode
58✔
775
      .findFirstOrThrow({
776
        where: { baseId, id: nodeId },
777
      })
778
      .catch(() => {
779
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
780
          localization: {
781
            i18nKey: 'httpErrors.baseNode.notFound',
782
          },
783
        });
784
      });
785

786
    if (isString(parentId) && isString(anchorId)) {
58✔
787
      throw new CustomHttpException(
×
788
        'Only one of parentId or anchorId must be provided',
789
        HttpErrorCode.VALIDATION_ERROR,
790
        {
791
          localization: {
792
            i18nKey: 'httpErrors.baseNode.onlyOneOfParentIdOrAnchorIdRequired',
793
          },
794
        }
795
      );
796
    }
797

798
    if (parentId === nodeId) {
58✔
799
      throw new CustomHttpException('Cannot move node to itself', HttpErrorCode.VALIDATION_ERROR, {
2✔
800
        localization: {
801
          i18nKey: 'httpErrors.baseNode.cannotMoveToItself',
802
        },
803
      });
804
    }
805

806
    if (anchorId === nodeId) {
56✔
807
      throw new CustomHttpException(
×
808
        'Cannot move node to its own child (circular reference)',
809
        HttpErrorCode.VALIDATION_ERROR,
810
        {
811
          localization: {
812
            i18nKey: 'httpErrors.baseNode.cannotMoveToCircularReference',
813
          },
814
        }
815
      );
816
    }
817

818
    let newNode: IBaseNodeEntry;
819
    if (anchorId) {
56✔
820
      newNode = await this.moveNodeTo(baseId, node.id, { anchorId, position });
14✔
821
    } else if (parentId === null) {
42✔
822
      newNode = await this.moveNodeToRoot(baseId, node.id);
4✔
823
    } else if (parentId) {
38✔
824
      newNode = await this.moveNodeToFolder(baseId, node.id, parentId);
38✔
825
    } else {
826
      throw new CustomHttpException(
×
827
        'At least one of parentId or anchorId must be provided',
828
        HttpErrorCode.VALIDATION_ERROR,
829
        {
830
          localization: {
831
            i18nKey: 'httpErrors.baseNode.anchorIdOrParentIdRequired',
832
          },
833
        }
834
      );
835
    }
836

837
    const vo = await this.entry2vo(newNode);
46✔
838
    this.presenceHandler(baseId, (presence) => {
46✔
839
      presence.submit({
46✔
840
        event: 'update',
841
        data: { ...vo },
842
      });
843
    });
844

845
    return vo;
46✔
846
  }
847

848
  private async moveNodeToRoot(baseId: string, nodeId: string) {
849
    return this.prismaService.$tx(async (prisma) => {
4✔
850
      const maxOrder = await this.getMaxOrder(baseId);
4✔
851
      return prisma.baseNode.update({
4✔
852
        where: { id: nodeId },
853
        select: this.getSelect(),
854
        data: {
855
          parentId: null,
856
          order: maxOrder + 1,
857
          lastModifiedBy: this.userId,
858
        },
859
      });
860
    });
861
  }
862

863
  private async moveNodeToFolder(baseId: string, nodeId: string, parentId: string) {
864
    return this.prismaService.$tx(async (prisma) => {
38✔
865
      const node = await prisma.baseNode
38✔
866
        .findFirstOrThrow({
867
          where: { baseId, id: nodeId },
868
        })
869
        .catch(() => {
870
          throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
871
            localization: {
872
              i18nKey: 'httpErrors.baseNode.notFound',
873
            },
874
          });
875
        });
876

877
      const parentNode = await prisma.baseNode
38✔
878
        .findFirstOrThrow({
879
          where: { baseId, id: parentId },
880
        })
881
        .catch(() => {
882
          throw new CustomHttpException(`Parent ${parentId} not found`, HttpErrorCode.NOT_FOUND, {
×
883
            localization: {
884
              i18nKey: 'httpErrors.baseNode.parentNotFound',
885
            },
886
          });
887
        });
888

889
      if (parentNode.resourceType !== BaseNodeResourceType.Folder) {
38✔
890
        throw new CustomHttpException(
2✔
891
          `Parent ${parentId} is not a folder`,
892
          HttpErrorCode.VALIDATION_ERROR,
893
          {
894
            localization: {
895
              i18nKey: 'httpErrors.baseNode.parentIsNotFolder',
896
            },
897
          }
898
        );
899
      }
900

901
      if (node.resourceType === BaseNodeResourceType.Folder && parentId) {
36✔
902
        await this.assertFolderDepth(baseId, parentId);
6✔
903
      }
904

905
      // Check for circular reference
906
      const isCircular = await this.isCircularReference(baseId, nodeId, parentId);
32✔
907
      if (isCircular) {
32✔
908
        throw new CustomHttpException(
×
909
          'Cannot move node to its own child (circular reference)',
910
          HttpErrorCode.VALIDATION_ERROR,
911
          {
912
            localization: {
913
              i18nKey: 'httpErrors.baseNode.circularReference',
914
            },
915
          }
916
        );
917
      }
918

919
      const maxOrder = await this.getMaxOrder(baseId);
32✔
920
      return prisma.baseNode.update({
32✔
921
        where: { id: nodeId },
922
        select: this.getSelect(),
923
        data: {
924
          parentId,
925
          order: maxOrder + 1,
926
          lastModifiedBy: this.userId,
927
        },
928
      });
929
    });
930
  }
931

932
  private async moveNodeTo(
933
    baseId: string,
934
    nodeId: string,
935
    ro: Pick<IMoveBaseNodeRo, 'anchorId' | 'position'>
936
  ): Promise<IBaseNodeEntry> {
937
    const { anchorId, position } = ro;
14✔
938
    return this.prismaService.$tx(async (prisma) => {
14✔
939
      const node = await prisma.baseNode
14✔
940
        .findFirstOrThrow({
941
          where: { baseId, id: nodeId },
942
        })
943
        .catch(() => {
944
          throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
945
            localization: {
946
              i18nKey: 'httpErrors.baseNode.notFound',
947
            },
948
          });
949
        });
950

951
      const anchor = await prisma.baseNode
14✔
952
        .findFirstOrThrow({
953
          where: { baseId, id: anchorId },
954
        })
955
        .catch(() => {
956
          throw new CustomHttpException(`Anchor ${anchorId} not found`, HttpErrorCode.NOT_FOUND, {
2✔
957
            localization: {
958
              i18nKey: 'httpErrors.baseNode.anchorNotFound',
959
            },
960
          });
961
        });
962

963
      if (node.resourceType === BaseNodeResourceType.Folder && anchor.parentId) {
12✔
964
        await this.assertFolderDepth(baseId, anchor.parentId);
8✔
965
      }
966

967
      await updateOrder({
10✔
968
        query: baseId,
969
        position: position ?? 'after',
970
        item: node,
971
        anchorItem: anchor,
972
        getNextItem: async (whereOrder, align) => {
973
          return prisma.baseNode.findFirst({
10✔
974
            where: {
975
              baseId,
976
              parentId: anchor.parentId,
977
              order: whereOrder,
978
            },
979
            select: { order: true, id: true },
980
            orderBy: { order: align },
981
          });
982
        },
983
        update: async (_, id, data) => {
984
          await prisma.baseNode.update({
10✔
985
            where: { id },
986
            data: { parentId: anchor.parentId, order: data.newOrder },
987
          });
988
        },
989
        shuffle: async () => {
990
          await this.shuffleOrders(baseId, anchor.parentId);
×
991
        },
992
      });
993

994
      return prisma.baseNode.findFirstOrThrow({
10✔
995
        where: { baseId, id: nodeId },
996
        select: this.getSelect(),
997
      });
998
    });
999
  }
1000

1001
  async getMaxOrder(baseId: string, parentId?: string | null) {
1002
    const prisma = this.prismaService.txClient();
282✔
1003
    const aggregate = await prisma.baseNode.aggregate({
282✔
1004
      where: { baseId, parentId },
1005
      _max: { order: true },
1006
    });
1007

1008
    return aggregate._max.order ?? 0;
282✔
1009
  }
1010

1011
  private async shuffleOrders(baseId: string, parentId: string | null) {
1012
    const prisma = this.prismaService.txClient();
×
1013
    const siblings = await prisma.baseNode.findMany({
×
1014
      where: { baseId, parentId },
1015
      orderBy: { order: 'asc' },
1016
    });
1017

1018
    for (const [index, sibling] of siblings.entries()) {
×
1019
      await prisma.baseNode.update({
×
1020
        where: { id: sibling.id },
1021
        data: { order: index + 10, lastModifiedBy: this.userId },
1022
      });
1023
    }
1024
  }
1025

1026
  private async getParentNodeOrThrow(id: string) {
1027
    const entry = await this.prismaService.baseNode.findFirst({
54✔
1028
      where: { id },
1029
      select: {
1030
        id: true,
1031
        parentId: true,
1032
        resourceType: true,
1033
        resourceId: true,
1034
      },
1035
    });
1036
    if (!entry) {
54✔
1037
      throw new CustomHttpException('Base node not found', HttpErrorCode.NOT_FOUND, {
2✔
1038
        localization: {
1039
          i18nKey: 'httpErrors.baseNode.notFound',
1040
        },
1041
      });
1042
    }
1043
    return entry;
52✔
1044
  }
1045

1046
  private async assertFolderDepth(baseId: string, id: string) {
1047
    const folderDepth = await this.getFolderDepth(baseId, id);
56✔
1048
    if (folderDepth >= maxFolderDepth) {
56✔
1049
      throw new CustomHttpException('Folder depth limit exceeded', HttpErrorCode.VALIDATION_ERROR, {
8✔
1050
        localization: {
1051
          i18nKey: 'httpErrors.baseNode.folderDepthLimitExceeded',
1052
        },
1053
      });
1054
    }
1055
  }
1056

1057
  private async getFolderDepth(baseId: string, id: string) {
1058
    const prisma = this.prismaService.txClient();
56✔
1059
    const allFolders = await prisma.baseNode.findMany({
56✔
1060
      where: { baseId, resourceType: BaseNodeResourceType.Folder },
1061
      select: { id: true, parentId: true },
1062
    });
1063

1064
    let depth = 0;
56✔
1065
    if (allFolders.length === 0) {
56✔
1066
      return depth;
×
1067
    }
1068

1069
    const folderMap = keyBy(allFolders, 'id');
56✔
1070
    let current = id;
56✔
1071
    while (current) {
56✔
1072
      depth++;
64✔
1073
      const folder = folderMap[current];
64✔
1074
      if (!folder) {
64✔
1075
        throw new CustomHttpException('Folder not found', HttpErrorCode.NOT_FOUND, {
×
1076
          localization: {
1077
            i18nKey: 'httpErrors.baseNode.folderNotFound',
1078
          },
1079
        });
1080
      }
1081
      if (folder.parentId === id) {
64✔
1082
        throw new CustomHttpException(
×
1083
          'A folder cannot be its own parent',
1084
          HttpErrorCode.VALIDATION_ERROR,
1085
          {
1086
            localization: {
1087
              i18nKey: 'httpErrors.baseNode.circularReference',
1088
            },
1089
          }
1090
        );
1091
      }
1092
      current = folder.parentId ?? '';
64✔
1093
    }
1094
    return depth;
56✔
1095
  }
1096

1097
  private async isCircularReference(
1098
    baseId: string,
1099
    nodeId: string,
1100
    parentId: string
1101
  ): Promise<boolean> {
1102
    const knex = this.knex;
32✔
1103

1104
    // Non-recursive query: Start with the parent node
1105
    const nonRecursiveQuery = knex
32✔
1106
      .select('id', 'parent_id', 'base_id')
1107
      .from('base_node')
1108
      .where('id', parentId)
1109
      .andWhere('base_id', baseId);
1110

1111
    // Recursive query: Traverse up the parent chain
1112
    const recursiveQuery = knex
32✔
1113
      .select('bn.id', 'bn.parent_id', 'bn.base_id')
1114
      .from('base_node as bn')
1115
      .innerJoin('ancestors as a', function () {
1116
        // Join condition: bn.id = a.parent_id (get parent of current ancestor)
1117
        this.on('bn.id', '=', 'a.parent_id').andOn('bn.base_id', '=', knex.raw('?', [baseId]));
32✔
1118
      });
1119

1120
    // Combine non-recursive and recursive queries
1121
    const cteQuery = nonRecursiveQuery.union(recursiveQuery);
32✔
1122

1123
    // Build final query with recursive CTE
1124
    const finalQuery = knex
32✔
1125
      .withRecursive('ancestors', ['id', 'parent_id', 'base_id'], cteQuery)
1126
      .select('id')
1127
      .from('ancestors')
1128
      .where('id', nodeId)
1129
      .limit(1)
1130
      .toQuery();
1131

1132
    // Execute query
1133
    const result = await this.prismaService
32✔
1134
      .txClient()
1135
      .$queryRawUnsafe<Array<{ id: string }>>(finalQuery);
1136

1137
    return result.length > 0;
32✔
1138
  }
1139

1140
  async batchUpdateBaseNodes(data: { id: string; values: { [key: string]: unknown } }[]) {
1141
    const sql = buildBatchUpdateSql(this.knex, data);
×
1142
    if (!sql) {
×
1143
      return;
×
1144
    }
1145
    await this.prismaService.txClient().$executeRawUnsafe(sql);
×
1146
  }
1147

1148
  private presenceHandler<
1149
    T =
1150
      | IBaseNodePresenceCreatePayload
1151
      | IBaseNodePresenceUpdatePayload
1152
      | IBaseNodePresenceDeletePayload,
1153
  >(baseId: string, handler: (presence: LocalPresence<T>) => void) {
1154
    this.performanceCacheService.del(generateBaseNodeListCacheKey(baseId));
500✔
1155
    // Skip if ShareDB connection is already closed (e.g., during shutdown)
1156
    if (this.shareDbService.shareDbAdapter.closed) {
500✔
1157
      this.logger.error('ShareDB connection is already closed, presence handler skipped');
×
1158
      return;
×
1159
    }
1160
    presenceHandler(baseId, this.shareDbService, handler);
500✔
1161
  }
1162
}
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