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

teableio / teable / 20255208517

16 Dec 2025 03:12AM UTC coverage: 71.678%. First build
20255208517

Pull #2280

github

web-flow
Merge e09e501f3 into 426709c11
Pull Request #2280: fix: base node curd tx

23030 of 25702 branches covered (89.6%)

88 of 104 new or added lines in 2 files covered. (84.62%)

58152 of 81129 relevant lines covered (71.68%)

4236.91 hits per line

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

74.55
/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
  IBaseNodePresenceCreatePayload,
20
  IBaseNodePresenceDeletePayload,
21
  IBaseNodePresenceFlushPayload,
22
  IBaseNodePresenceUpdatePayload,
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 { generateBaseNodeListCacheKey } from '../../performance-cache/generate-keys';
32
import { PerformanceCacheService } from '../../performance-cache/service';
33
import type { IPerformanceCacheStore } from '../../performance-cache/types';
34
import { ShareDbService } from '../../share-db/share-db.service';
35
import type { IClsStore } from '../../types/cls';
36
import { updateOrder } from '../../utils/update-order';
37
import { DashboardService } from '../dashboard/dashboard.service';
38
import { TableOpenApiService } from '../table/open-api/table-open-api.service';
39
import { prepareCreateTableRo } from '../table/open-api/table.pipe.helper';
40
import { TableDuplicateService } from '../table/table-duplicate.service';
41
import { BaseNodeFolderService } from './folder/base-node-folder.service';
42
import { buildBatchUpdateSql, presenceHandler } from './helper';
43

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

55
// max depth is maxFolderDepth + 1
5✔
56
const maxFolderDepth = 2;
5✔
57

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

73
  private get userId() {
128✔
74
    return this.cls.get('user.id');
120✔
75
  }
120✔
76

77
  private getSelect() {
128✔
78
    return {
156✔
79
      id: true,
156✔
80
      baseId: true,
156✔
81
      parentId: true,
156✔
82
      resourceType: true,
156✔
83
      resourceId: true,
156✔
84
      order: true,
156✔
85
      children: {
156✔
86
        select: { id: true, order: true },
156✔
87
        orderBy: { order: 'asc' as const },
156✔
88
      },
156✔
89
      parent: {
156✔
90
        select: { id: true },
156✔
91
      },
156✔
92
    };
156✔
93
  }
156✔
94

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

118
  protected getTableResources(baseId: string, ids?: string[]) {
128✔
119
    return this.prismaService.tableMeta.findMany({
33✔
120
      where: { baseId, id: { in: ids ? ids : undefined }, deletedTime: null },
33✔
121
      select: {
33✔
122
        id: true,
33✔
123
        name: true,
33✔
124
        icon: true,
33✔
125
      },
33✔
126
    });
33✔
127
  }
33✔
128

129
  protected getDashboardResources(baseId: string, ids?: string[]) {
128✔
130
    return this.prismaService.dashboard.findMany({
19✔
131
      where: { baseId, id: { in: ids ? ids : undefined } },
19✔
132
      select: {
19✔
133
        id: true,
19✔
134
        name: true,
19✔
135
      },
19✔
136
    });
19✔
137
  }
19✔
138

139
  protected getFolderResources(baseId: string, ids?: string[]) {
128✔
140
    return this.prismaService.baseNodeFolder.findMany({
25✔
141
      where: { baseId, id: { in: ids ? ids : undefined } },
25✔
142
      select: {
25✔
143
        id: true,
25✔
144
        name: true,
25✔
145
      },
25✔
146
    });
25✔
147
  }
25✔
148

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

174
  protected getResourceTypes(): BaseNodeResourceType[] {
128✔
175
    return [
16✔
176
      BaseNodeResourceType.Folder,
16✔
177
      BaseNodeResourceType.Table,
16✔
178
      BaseNodeResourceType.Dashboard,
16✔
179
    ];
16✔
180
  }
16✔
181

182
  async prepareNodeList(baseId: string): Promise<IBaseNodeVo[]> {
128✔
183
    const resourceTypes = this.getResourceTypes();
16✔
184
    const resourceResults = await Promise.all(
16✔
185
      resourceTypes.map((type) => this.getNodeResource(baseId, type))
16✔
186
    );
187

188
    const resources = resourceResults.flatMap((list, index) =>
16✔
189
      list.map((r) => ({ ...r, type: resourceTypes[index] }))
48✔
190
    );
191

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

195
    const nodes = await this.prismaService.baseNode.findMany({
16✔
196
      where: { baseId },
16✔
197
      select: this.getSelect(),
16✔
198
      orderBy: { order: 'asc' },
16✔
199
    });
16✔
200

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

203
    const toCreate = resources.filter((r) => !nodeKeys.has(`${r.type}_${r.id}`));
16✔
204
    const toDelete = nodes.filter((n) => !resourceKeys.has(`${n.resourceType}_${n.resourceId}`));
16✔
205
    const validParentIds = new Set(nodes.filter((n) => !toDelete.includes(n)).map((n) => n.id));
16✔
206
    const orphans = nodes.filter(
16✔
207
      (n) => n.parentId && !validParentIds.has(n.parentId) && !toDelete.includes(n)
16✔
208
    );
209

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

222
    const finalMenus = await this.prismaService.$tx(async (prisma) => {
×
223
      // Delete redundant
×
224
      if (toDelete.length > 0) {
×
225
        await prisma.baseNode.deleteMany({
×
226
          where: { id: { in: toDelete.map((m) => m.id) } },
×
227
        });
×
228
      }
×
229

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

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

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

271
    return await Promise.all(
×
272
      finalMenus.map(async (entry) => {
×
273
        const key = `${entry.resourceType}_${entry.resourceId}`;
×
274
        const resource = resourceMap[key];
×
275
        return await this.entry2vo(entry, omit(resource, 'id'));
×
276
      })
×
277
    );
278
  }
×
279

280
  async getNodeListWithCache(baseId: string): Promise<IBaseNodeVo[]> {
128✔
281
    return this.performanceCacheService.wrap(
16✔
282
      generateBaseNodeListCacheKey(baseId),
16✔
283
      () => this.prepareNodeList(baseId),
16✔
284
      {
16✔
285
        ttl: 60 * 60, // 1 hour
16✔
286
        statsType: 'base-node-list',
16✔
287
      }
16✔
288
    );
289
  }
16✔
290

291
  async getList(baseId: string): Promise<IBaseNodeVo[]> {
128✔
292
    return this.getNodeListWithCache(baseId);
3✔
293
  }
3✔
294

295
  async getTree(baseId: string): Promise<IBaseNodeTreeVo> {
128✔
296
    const nodes = await this.getNodeListWithCache(baseId);
13✔
297

298
    return {
13✔
299
      nodes,
13✔
300
      maxFolderDepth,
13✔
301
    };
13✔
302
  }
13✔
303

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

323
  async getNodeVo(baseId: string, nodeId: string): Promise<IBaseNodeVo> {
128✔
324
    const node = await this.getNode(baseId, nodeId);
8✔
325
    return this.entry2vo(node);
8✔
326
  }
8✔
327

328
  async create(baseId: string, ro: ICreateBaseNodeRo): Promise<IBaseNodeVo> {
128✔
329
    const { resourceType, parentId } = ro;
110✔
330

331
    const resource = await this.createResource(baseId, ro);
110✔
332
    const resourceId = resource.id;
107✔
333

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

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

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

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

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

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

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

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

430
    const resource = await this.duplicateResource(
4✔
431
      baseId,
4✔
432
      resourceType as BaseNodeResourceType,
4✔
433
      resourceId,
4✔
434
      ro
4✔
435
    );
436

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

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

481
      return {
4✔
482
        entry,
4✔
483
      };
4✔
484
    });
4✔
485

486
    return this.entry2vo(entry, omit(resource, 'id'));
4✔
487
  }
4✔
488

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

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

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

545
    await this.updateResource(
7✔
546
      baseId,
7✔
547
      node.resourceType as BaseNodeResourceType,
7✔
548
      node.resourceId,
7✔
549
      ro
7✔
550
    );
551

552
    return this.entry2vo(node);
7✔
553
  }
7✔
554

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

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

624
    await this.deleteResource(
97✔
625
      baseId,
97✔
626
      node.resourceType as BaseNodeResourceType,
97✔
627
      node.resourceId,
97✔
628
      permanent
97✔
629
    );
630
    await this.prismaService.baseNode.delete({
97✔
631
      where: { id: nodeId },
97✔
632
    });
97✔
633

634
    return node;
97✔
635
  }
97✔
636

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

670
  async move(baseId: string, nodeId: string, ro: IMoveBaseNodeRo): Promise<IBaseNodeVo> {
128✔
671
    const { parentId, anchorId, position } = ro;
20✔
672

673
    const node = await this.prismaService.baseNode
20✔
674
      .findFirstOrThrow({
20✔
675
        where: { baseId, id: nodeId },
20✔
676
      })
20✔
677
      .catch(() => {
20✔
678
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
679
          localization: {
×
680
            i18nKey: 'httpErrors.baseNode.notFound',
×
681
          },
×
682
        });
×
683
      });
×
684

685
    if (isString(parentId) && isString(anchorId)) {
20!
686
      throw new CustomHttpException(
×
687
        'Only one of parentId or anchorId must be provided',
×
688
        HttpErrorCode.VALIDATION_ERROR,
×
689
        {
×
690
          localization: {
×
691
            i18nKey: 'httpErrors.baseNode.onlyOneOfParentIdOrAnchorIdRequired',
×
692
          },
×
693
        }
×
694
      );
695
    }
×
696

697
    if (parentId === nodeId) {
20!
698
      throw new CustomHttpException('Cannot move node to itself', HttpErrorCode.VALIDATION_ERROR, {
1✔
699
        localization: {
1✔
700
          i18nKey: 'httpErrors.baseNode.cannotMoveToItself',
1✔
701
        },
1✔
702
      });
1✔
703
    }
1✔
704

705
    if (anchorId === nodeId) {
20!
706
      throw new CustomHttpException(
×
707
        'Cannot move node to its own child (circular reference)',
×
708
        HttpErrorCode.VALIDATION_ERROR,
×
NEW
709
        {
×
NEW
710
          localization: {
×
711
            i18nKey: 'httpErrors.baseNode.cannotMoveToCircularReference',
×
712
          },
×
713
        }
×
714
      );
715
    }
✔
716

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

736
    const vo = await this.entry2vo(newNode);
14✔
737
    this.presenceHandler(baseId, (presence) => {
14✔
738
      presence.submit({
14✔
739
        event: 'update',
14✔
740
        data: { ...vo },
14✔
741
      });
14✔
742
    });
14✔
743

744
    return vo;
14✔
745
  }
14✔
746

747
  private async moveNodeToRoot(baseId: string, nodeId: string) {
128✔
748
    return this.prismaService.$tx(async (prisma) => {
2✔
749
      const maxOrder = await this.getMaxOrder(baseId);
2✔
750
      return prisma.baseNode.update({
2✔
751
        where: { id: nodeId },
2✔
752
        select: this.getSelect(),
2✔
753
        data: {
2✔
754
          parentId: null,
2✔
755
          order: maxOrder + 1,
2✔
756
          lastModifiedBy: this.userId,
2✔
757
        },
2✔
758
      });
2✔
759
    });
2✔
760
  }
2✔
761

762
  private async moveNodeToFolder(baseId: string, nodeId: string, parentId: string) {
128✔
763
    return this.prismaService.$tx(async (prisma) => {
10✔
764
      const node = await prisma.baseNode
10✔
765
        .findFirstOrThrow({
10✔
766
          where: { baseId, id: nodeId },
10✔
767
        })
10✔
768
        .catch(() => {
10✔
769
          throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
770
            localization: {
×
771
              i18nKey: 'httpErrors.baseNode.notFound',
×
772
            },
×
773
          });
×
774
        });
×
775

776
      const parentNode = await prisma.baseNode
10✔
777
        .findFirstOrThrow({
10✔
778
          where: { baseId, id: parentId },
10✔
779
        })
10✔
780
        .catch(() => {
10✔
781
          throw new CustomHttpException(`Parent ${parentId} not found`, HttpErrorCode.NOT_FOUND, {
×
782
            localization: {
×
783
              i18nKey: 'httpErrors.baseNode.parentNotFound',
×
784
            },
×
785
          });
×
786
        });
×
787

788
      if (parentNode.resourceType !== BaseNodeResourceType.Folder) {
10!
789
        throw new CustomHttpException(
1✔
790
          `Parent ${parentId} is not a folder`,
1✔
791
          HttpErrorCode.VALIDATION_ERROR,
1✔
792
          {
1✔
793
            localization: {
1✔
794
              i18nKey: 'httpErrors.baseNode.parentIsNotFolder',
1✔
795
            },
1✔
796
          }
1✔
797
        );
798
      }
1✔
799

800
      if (node.resourceType === BaseNodeResourceType.Folder && parentId) {
10!
801
        await this.assertFolderDepth(baseId, parentId);
3✔
802
      }
1✔
803

804
      // Check for circular reference
7✔
805
      const isCircular = await this.isCircularReference(baseId, nodeId, parentId);
7✔
806
      if (isCircular) {
10!
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.circularReference',
×
813
            },
×
814
          }
×
815
        );
816
      }
✔
817

818
      const maxOrder = await this.getMaxOrder(baseId);
7✔
819
      return prisma.baseNode.update({
7✔
820
        where: { id: nodeId },
7✔
821
        select: this.getSelect(),
7✔
822
        data: {
7✔
823
          parentId,
7✔
824
          order: maxOrder + 1,
7✔
825
          lastModifiedBy: this.userId,
7✔
826
        },
7✔
827
      });
7✔
828
    });
7✔
829
  }
10✔
830

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

850
      const anchor = await prisma.baseNode
7✔
851
        .findFirstOrThrow({
7✔
852
          where: { baseId, id: anchorId },
7✔
853
        })
7✔
854
        .catch(() => {
7✔
855
          throw new CustomHttpException(`Anchor ${anchorId} not found`, HttpErrorCode.NOT_FOUND, {
1✔
856
            localization: {
1✔
857
              i18nKey: 'httpErrors.baseNode.anchorNotFound',
1✔
858
            },
1✔
859
          });
1✔
860
        });
1✔
861

862
      if (node.resourceType === BaseNodeResourceType.Folder && anchor.parentId) {
7✔
863
        await this.assertFolderDepth(baseId, anchor.parentId);
4✔
864
      }
3✔
865

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

893
      return prisma.baseNode.findFirstOrThrow({
5✔
894
        where: { baseId, id: nodeId },
5✔
895
        select: this.getSelect(),
5✔
896
      });
5✔
897
    });
5✔
898
  }
7✔
899

900
  async getMaxOrder(baseId: string, parentId?: string | null) {
128✔
901
    const prisma = this.prismaService.txClient();
120✔
902
    const aggregate = await prisma.baseNode.aggregate({
120✔
903
      where: { baseId, parentId },
120✔
904
      _max: { order: true },
120✔
905
    });
120✔
906

907
    return aggregate._max.order ?? 0;
120✔
908
  }
120✔
909

910
  private async shuffleOrders(baseId: string, parentId: string | null) {
128✔
911
    const prisma = this.prismaService.txClient();
×
912
    const siblings = await prisma.baseNode.findMany({
×
913
      where: { baseId, parentId },
×
914
      orderBy: { order: 'asc' },
×
915
    });
×
916

917
    for (const [index, sibling] of siblings.entries()) {
×
918
      await prisma.baseNode.update({
×
919
        where: { id: sibling.id },
×
920
        data: { order: index + 10, lastModifiedBy: this.userId },
×
921
      });
×
922
    }
×
923
  }
×
924

925
  private async getParentNodeOrThrow(id: string) {
128✔
926
    const entry = await this.prismaService.baseNode.findFirst({
27✔
927
      where: { id },
27✔
928
      select: {
27✔
929
        id: true,
27✔
930
        parentId: true,
27✔
931
        resourceType: true,
27✔
932
        resourceId: true,
27✔
933
      },
27✔
934
    });
27✔
935
    if (!entry) {
27✔
936
      throw new CustomHttpException('Base node not found', HttpErrorCode.NOT_FOUND, {
1✔
937
        localization: {
1✔
938
          i18nKey: 'httpErrors.baseNode.notFound',
1✔
939
        },
1✔
940
      });
1✔
941
    }
1✔
942
    return entry;
26✔
943
  }
26✔
944

945
  private async assertFolderDepth(baseId: string, id: string) {
128✔
946
    const folderDepth = await this.getFolderDepth(baseId, id);
28✔
947
    if (folderDepth >= maxFolderDepth) {
28✔
948
      throw new CustomHttpException('Folder depth limit exceeded', HttpErrorCode.VALIDATION_ERROR, {
4✔
949
        localization: {
4✔
950
          i18nKey: 'httpErrors.baseNode.folderDepthLimitExceeded',
4✔
951
        },
4✔
952
      });
4✔
953
    }
4✔
954
  }
28✔
955

956
  private async getFolderDepth(baseId: string, id: string) {
128✔
957
    const prisma = this.prismaService.txClient();
28✔
958
    const allFolders = await prisma.baseNode.findMany({
28✔
959
      where: { baseId, resourceType: BaseNodeResourceType.Folder },
28✔
960
      select: { id: true, parentId: true },
28✔
961
    });
28✔
962

963
    let depth = 0;
28✔
964
    if (allFolders.length === 0) {
28!
965
      return depth;
×
966
    }
×
967

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

996
  private async isCircularReference(
128✔
997
    baseId: string,
7✔
998
    nodeId: string,
7✔
999
    parentId: string
7✔
1000
  ): Promise<boolean> {
7✔
1001
    const knex = this.knex;
7✔
1002

1003
    // Non-recursive query: Start with the parent node
7✔
1004
    const nonRecursiveQuery = knex
7✔
1005
      .select('id', 'parent_id', 'base_id')
7✔
1006
      .from('base_node')
7✔
1007
      .where('id', parentId)
7✔
1008
      .andWhere('base_id', baseId);
7✔
1009

1010
    // Recursive query: Traverse up the parent chain
7✔
1011
    const recursiveQuery = knex
7✔
1012
      .select('bn.id', 'bn.parent_id', 'bn.base_id')
7✔
1013
      .from('base_node as bn')
7✔
1014
      .innerJoin('ancestors as a', function () {
7✔
1015
        // Join condition: bn.id = a.parent_id (get parent of current ancestor)
7✔
1016
        this.on('bn.id', '=', 'a.parent_id').andOn('bn.base_id', '=', knex.raw('?', [baseId]));
7✔
1017
      });
7✔
1018

1019
    // Combine non-recursive and recursive queries
7✔
1020
    const cteQuery = nonRecursiveQuery.union(recursiveQuery);
7✔
1021

1022
    // Build final query with recursive CTE
7✔
1023
    const finalQuery = knex
7✔
1024
      .withRecursive('ancestors', ['id', 'parent_id', 'base_id'], cteQuery)
7✔
1025
      .select('id')
7✔
1026
      .from('ancestors')
7✔
1027
      .where('id', nodeId)
7✔
1028
      .limit(1)
7✔
1029
      .toQuery();
7✔
1030

1031
    // Execute query
7✔
1032
    const result = await this.prismaService
7✔
1033
      .txClient()
7✔
1034
      .$queryRawUnsafe<Array<{ id: string }>>(finalQuery);
7✔
1035

1036
    return result.length > 0;
7✔
1037
  }
7✔
1038

1039
  async batchUpdateBaseNodes(data: { id: string; values: { [key: string]: unknown } }[]) {
128✔
1040
    const sql = buildBatchUpdateSql(this.knex, data);
×
1041
    if (!sql) {
×
1042
      return;
×
1043
    }
×
1044
    await this.prismaService.txClient().$executeRawUnsafe(sql);
×
1045
  }
×
1046

1047
  private presenceHandler<
128✔
1048
    T =
1049
      | IBaseNodePresenceFlushPayload
1050
      | IBaseNodePresenceCreatePayload
1051
      | IBaseNodePresenceUpdatePayload
1052
      | IBaseNodePresenceDeletePayload,
1053
  >(baseId: string, handler: (presence: LocalPresence<T>) => void) {
14✔
1054
    this.performanceCacheService.del(generateBaseNodeListCacheKey(baseId));
14✔
1055
    // Skip if ShareDB connection is already closed (e.g., during shutdown)
14✔
1056
    if (this.shareDbService.shareDbAdapter.closed) {
14!
1057
      this.logger.error('ShareDB connection is already closed, presence handler skipped');
×
1058
      return;
×
1059
    }
×
1060
    presenceHandler(baseId, this.shareDbService, handler);
14✔
1061
  }
14✔
1062
}
128✔
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