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

teableio / teable / 20427413423

22 Dec 2025 09:16AM UTC coverage: 71.833%. First build
20427413423

Pull #2317

github

web-flow
Merge e3f398cb0 into c6be0833e
Pull Request #2317: feat: fix template cover upload logic T1372

23946 of 26788 branches covered (89.39%)

60 of 141 new or added lines in 3 files covered. (42.55%)

59569 of 82927 relevant lines covered (71.83%)

4184.35 hits per line

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

76.12
/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
  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 { 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);
135✔
61
  constructor(
135✔
62
    private readonly performanceCacheService: PerformanceCacheService<IPerformanceCacheStore>,
135✔
63
    private readonly shareDbService: ShareDbService,
135✔
64
    private readonly prismaService: PrismaService,
135✔
65
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
135✔
66
    private readonly cls: ClsService<IClsStore & { ignoreBaseNodeListener?: boolean }>,
135✔
67
    private readonly baseNodeFolderService: BaseNodeFolderService,
135✔
68
    private readonly tableOpenApiService: TableOpenApiService,
135✔
69
    private readonly tableDuplicateService: TableDuplicateService,
135✔
70
    private readonly dashboardService: DashboardService
135✔
71
  ) {}
135✔
72

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

77
  private setIgnoreBaseNodeListener() {
135✔
78
    this.cls.set('ignoreBaseNodeListener', true);
241✔
79
  }
241✔
80

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

99
  private generateDefaultUrl(
135✔
100
    baseId: string,
204✔
101
    resourceType: BaseNodeResourceType,
204✔
102
    resourceId: string,
204✔
103
    resourceMeta?: IBaseNodeResourceMeta
204✔
104
  ): string {
204✔
105
    switch (resourceType) {
204✔
106
      case BaseNodeResourceType.Table: {
204✔
107
        const tableMeta = resourceMeta as IBaseNodeTableResourceMeta | undefined;
63✔
108
        const viewId = tableMeta?.defaultViewId;
63✔
109
        if (viewId) {
63✔
110
          return `/base/${baseId}/table/${resourceId}/${viewId}`;
29✔
111
        }
29✔
112
        return `/base/${baseId}/table/${resourceId}`;
34✔
113
      }
34✔
114
      case BaseNodeResourceType.Dashboard:
204✔
115
        return `/base/${baseId}/dashboard/${resourceId}`;
30✔
116
      case BaseNodeResourceType.Workflow:
204✔
NEW
117
        return `/base/${baseId}/automation/${resourceId}`;
×
118
      case BaseNodeResourceType.App:
204✔
NEW
119
        return `/base/${baseId}/app/${resourceId}`;
×
120
      case BaseNodeResourceType.Folder:
204✔
121
        return `/base/${baseId}`;
111✔
122
      default:
204✔
NEW
123
        return `/base/${baseId}`;
×
124
    }
204✔
125
  }
204✔
126

127
  private async entry2vo(
135✔
128
    entry: IBaseNodeEntry,
140✔
129
    resource?: IBaseNodeResourceMeta
140✔
130
  ): Promise<IBaseNodeVo> {
140✔
131
    const resourceMeta =
140✔
132
      resource ||
140✔
133
      (
29✔
134
        await this.getNodeResource(entry.baseId, entry.resourceType as BaseNodeResourceType, [
29✔
135
          entry.resourceId,
29✔
136
        ])
29✔
137
      )[0];
140✔
138
    const resourceMetaWithoutId = resource ? resource : omit(resourceMeta, 'id');
140✔
139

140
    const defaultUrl = this.generateDefaultUrl(
140✔
141
      entry.baseId,
140✔
142
      entry.resourceType as BaseNodeResourceType,
140✔
143
      entry.resourceId,
140✔
144
      resourceMetaWithoutId
140✔
145
    );
146

147
    return {
140✔
148
      ...entry,
140✔
149
      resourceType: entry.resourceType as BaseNodeResourceType,
140✔
150
      resourceMeta: resourceMetaWithoutId,
140✔
151
      defaultUrl,
140✔
152
    };
140✔
153
  }
140✔
154

155
  protected getTableResources(baseId: string, ids?: string[]) {
135✔
156
    return this.prismaService.tableMeta.findMany({
33✔
157
      where: { baseId, id: { in: ids ? ids : undefined }, deletedTime: null },
33✔
158
      select: {
33✔
159
        id: true,
33✔
160
        name: true,
33✔
161
        icon: true,
33✔
162
      },
33✔
163
    });
33✔
164
  }
33✔
165

166
  protected getDashboardResources(baseId: string, ids?: string[]) {
135✔
167
    return this.prismaService.dashboard.findMany({
19✔
168
      where: { baseId, id: { in: ids ? ids : undefined } },
19✔
169
      select: {
19✔
170
        id: true,
19✔
171
        name: true,
19✔
172
      },
19✔
173
    });
19✔
174
  }
19✔
175

176
  protected getFolderResources(baseId: string, ids?: string[]) {
135✔
177
    return this.prismaService.baseNodeFolder.findMany({
25✔
178
      where: { baseId, id: { in: ids ? ids : undefined } },
25✔
179
      select: {
25✔
180
        id: true,
25✔
181
        name: true,
25✔
182
      },
25✔
183
    });
25✔
184
  }
25✔
185

186
  protected async getNodeResource(
135✔
187
    baseId: string,
77✔
188
    type: BaseNodeResourceType,
77✔
189
    ids?: string[]
77✔
190
  ): Promise<IBaseNodeResourceMetaWithId[]> {
77✔
191
    switch (type) {
77✔
192
      case BaseNodeResourceType.Folder:
77✔
193
        return this.getFolderResources(baseId, ids);
25✔
194
      case BaseNodeResourceType.Table:
77✔
195
        return this.getTableResources(baseId, ids);
33✔
196
      case BaseNodeResourceType.Dashboard:
77✔
197
        return this.getDashboardResources(baseId, ids);
19✔
198
      default:
77✔
199
        throw new CustomHttpException(
×
200
          `Invalid resource type ${type}`,
×
201
          HttpErrorCode.VALIDATION_ERROR,
×
202
          {
×
203
            localization: {
×
204
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
×
205
            },
×
206
          }
×
207
        );
208
    }
77✔
209
  }
77✔
210

211
  protected getResourceTypes(): BaseNodeResourceType[] {
135✔
212
    return [
16✔
213
      BaseNodeResourceType.Folder,
16✔
214
      BaseNodeResourceType.Table,
16✔
215
      BaseNodeResourceType.Dashboard,
16✔
216
    ];
16✔
217
  }
16✔
218

219
  async prepareNodeList(baseId: string): Promise<IBaseNodeVo[]> {
135✔
220
    const resourceTypes = this.getResourceTypes();
16✔
221
    const resourceResults = await Promise.all(
16✔
222
      resourceTypes.map((type) => this.getNodeResource(baseId, type))
16✔
223
    );
224

225
    const resources = resourceResults.flatMap((list, index) =>
16✔
226
      list.map((r) => ({ ...r, type: resourceTypes[index] }))
48✔
227
    );
228

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

232
    const nodes = await this.prismaService.baseNode.findMany({
16✔
233
      where: { baseId },
16✔
234
      select: this.getSelect(),
16✔
235
      orderBy: { order: 'asc' },
16✔
236
    });
16✔
237

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

240
    const toCreate = resources.filter((r) => !nodeKeys.has(`${r.type}_${r.id}`));
16✔
241
    const toDelete = nodes.filter((n) => !resourceKeys.has(`${n.resourceType}_${n.resourceId}`));
16✔
242
    const validParentIds = new Set(nodes.filter((n) => !toDelete.includes(n)).map((n) => n.id));
16✔
243
    const orphans = nodes.filter(
16✔
244
      (n) => n.parentId && !validParentIds.has(n.parentId) && !toDelete.includes(n)
16✔
245
    );
246

247
    if (toCreate.length === 0 && toDelete.length === 0 && orphans.length === 0) {
16✔
248
      return nodes.map((entry) => {
16✔
249
        const key = `${entry.resourceType}_${entry.resourceId}`;
64✔
250
        const resource = resourceMap[key];
64✔
251
        const resourceMeta = omit(resource, 'id');
64✔
252
        const defaultUrl = this.generateDefaultUrl(
64✔
253
          baseId,
64✔
254
          entry.resourceType as BaseNodeResourceType,
64✔
255
          entry.resourceId,
64✔
256
          resourceMeta
64✔
257
        );
258
        return {
64✔
259
          ...entry,
64✔
260
          resourceType: entry.resourceType as BaseNodeResourceType,
64✔
261
          resourceMeta,
64✔
262
          defaultUrl,
64✔
263
        };
64✔
264
      });
64✔
265
    }
16✔
266

267
    const finalMenus = await this.prismaService.$tx(async (prisma) => {
×
268
      // Delete redundant
×
269
      if (toDelete.length > 0) {
×
270
        await prisma.baseNode.deleteMany({
×
271
          where: { id: { in: toDelete.map((m) => m.id) } },
×
272
        });
×
273
      }
×
274

275
      // Prepare for create and update
×
276
      let nextOrder = 0;
×
277
      if (toCreate.length > 0 || orphans.length > 0) {
×
278
        const maxOrderAgg = await prisma.baseNode.aggregate({
×
279
          where: { baseId },
×
280
          _max: { order: true },
×
281
        });
×
282
        nextOrder = (maxOrderAgg._max.order ?? 0) + 1;
×
283
      }
×
284

285
      // Create missing
×
286
      if (toCreate.length > 0) {
×
287
        await prisma.baseNode.createMany({
×
288
          data: toCreate.map((r) => ({
×
289
            id: generateBaseNodeId(),
×
290
            baseId,
×
291
            resourceType: r.type,
×
292
            resourceId: r.id,
×
293
            order: nextOrder++,
×
294
            parentId: null,
×
295
            createdBy: this.userId,
×
296
          })),
×
297
        });
×
298
      }
×
299

300
      // Reset orphans to root level with new order
×
301
      if (orphans.length > 0) {
×
302
        await this.batchUpdateBaseNodes(
×
303
          orphans.map((orphan, index) => ({
×
304
            id: orphan.id,
×
305
            values: { parentId: null, order: nextOrder + index },
×
306
          }))
×
307
        );
308
      }
×
309
      return prisma.baseNode.findMany({
×
310
        where: { baseId },
×
311
        select: this.getSelect(),
×
312
        orderBy: { order: 'asc' },
×
313
      });
×
314
    });
×
315

316
    return await Promise.all(
×
317
      finalMenus.map(async (entry) => {
×
318
        const key = `${entry.resourceType}_${entry.resourceId}`;
×
319
        const resource = resourceMap[key];
×
320
        return await this.entry2vo(entry, omit(resource, 'id'));
×
321
      })
×
322
    );
323
  }
×
324

325
  async getNodeListWithCache(baseId: string): Promise<IBaseNodeVo[]> {
135✔
326
    return this.performanceCacheService.wrap(
16✔
327
      generateBaseNodeListCacheKey(baseId),
16✔
328
      () => this.prepareNodeList(baseId),
16✔
329
      {
16✔
330
        ttl: 60 * 60, // 1 hour
16✔
331
        statsType: 'base-node-list',
16✔
332
      }
16✔
333
    );
334
  }
16✔
335

336
  async getList(baseId: string): Promise<IBaseNodeVo[]> {
135✔
337
    return this.getNodeListWithCache(baseId);
3✔
338
  }
3✔
339

340
  async getTree(baseId: string): Promise<IBaseNodeTreeVo> {
135✔
341
    const nodes = await this.getNodeListWithCache(baseId);
13✔
342

343
    return {
13✔
344
      nodes,
13✔
345
      maxFolderDepth,
13✔
346
    };
13✔
347
  }
13✔
348

349
  async getNode(baseId: string, nodeId: string) {
135✔
350
    const node = await this.prismaService.baseNode
8✔
351
      .findFirstOrThrow({
8✔
352
        where: { baseId, id: nodeId },
8✔
353
        select: this.getSelect(),
8✔
354
      })
8✔
355
      .catch(() => {
8✔
356
        throw new CustomHttpException(`Base node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
357
          localization: {
×
358
            i18nKey: 'httpErrors.baseNode.notFound',
×
359
          },
×
360
        });
×
361
      });
×
362
    return {
8✔
363
      ...node,
8✔
364
      resourceType: node.resourceType as BaseNodeResourceType,
8✔
365
    };
8✔
366
  }
8✔
367

368
  async getNodeVo(baseId: string, nodeId: string): Promise<IBaseNodeVo> {
135✔
369
    const node = await this.getNode(baseId, nodeId);
8✔
370
    return this.entry2vo(node);
8✔
371
  }
8✔
372

373
  async create(baseId: string, ro: ICreateBaseNodeRo): Promise<IBaseNodeVo> {
135✔
374
    this.setIgnoreBaseNodeListener();
110✔
375

376
    const { resourceType, parentId } = ro;
110✔
377
    const resource = await this.createResource(baseId, ro);
110✔
378
    const resourceId = resource.id;
107✔
379

380
    const maxOrder = await this.getMaxOrder(baseId);
107✔
381
    const entry = await this.prismaService.baseNode.create({
107✔
382
      data: {
107✔
383
        id: generateBaseNodeId(),
107✔
384
        baseId,
107✔
385
        resourceType,
107✔
386
        resourceId,
107✔
387
        order: maxOrder + 1,
107✔
388
        parentId,
107✔
389
        createdBy: this.userId,
107✔
390
      },
107✔
391
      select: this.getSelect(),
107✔
392
    });
107✔
393

394
    const vo = await this.entry2vo(entry, omit(resource, 'id'));
107✔
395
    this.presenceHandler(baseId, (presence) => {
107✔
396
      presence.submit({
107✔
397
        event: 'create',
107✔
398
        data: { ...vo },
107✔
399
      });
107✔
400
    });
107✔
401

402
    return vo;
107✔
403
  }
107✔
404

405
  protected async createResource(
135✔
406
    baseId: string,
110✔
407
    createRo: ICreateBaseNodeRo
110✔
408
  ): Promise<IBaseNodeResourceMetaWithId> {
110✔
409
    const { resourceType, parentId, ...ro } = createRo;
110✔
410
    const parentNode = parentId ? await this.getParentNodeOrThrow(parentId) : null;
110✔
411
    if (parentNode && parentNode.resourceType !== BaseNodeResourceType.Folder) {
110✔
412
      throw new CustomHttpException('Parent must be a folder', HttpErrorCode.VALIDATION_ERROR, {
1✔
413
        localization: {
1✔
414
          i18nKey: 'httpErrors.baseNode.parentMustBeFolder',
1✔
415
        },
1✔
416
      });
1✔
417
    }
1✔
418

419
    if (parentNode && resourceType === BaseNodeResourceType.Folder) {
110✔
420
      await this.assertFolderDepth(baseId, parentNode.id);
21✔
421
    }
20✔
422

423
    switch (resourceType) {
107✔
424
      case BaseNodeResourceType.Folder: {
110✔
425
        const folder = await this.baseNodeFolderService.createFolder(
70✔
426
          baseId,
70✔
427
          ro as ICreateFolderNodeRo
70✔
428
        );
429
        return { id: folder.id, name: folder.name };
70✔
430
      }
70✔
431
      case BaseNodeResourceType.Table: {
110✔
432
        const preparedRo = prepareCreateTableRo(ro as ICreateTableRo);
27✔
433
        const table = await this.tableOpenApiService.createTable(baseId, preparedRo);
27✔
434

435
        return {
27✔
436
          id: table.id,
27✔
437
          name: table.name,
27✔
438
          icon: table.icon,
27✔
439
          defaultViewId: table.defaultViewId,
27✔
440
        };
27✔
441
      }
27✔
442
      case BaseNodeResourceType.Dashboard: {
110✔
443
        const dashboard = await this.dashboardService.createDashboard(
10✔
444
          baseId,
10✔
445
          ro as ICreateDashboardRo
10✔
446
        );
447
        return { id: dashboard.id, name: dashboard.name };
10✔
448
      }
10✔
449
      default:
110!
450
        throw new CustomHttpException(
×
451
          `Invalid resource type ${resourceType}`,
×
452
          HttpErrorCode.VALIDATION_ERROR,
×
453
          {
×
454
            localization: {
×
455
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
×
456
            },
×
457
          }
×
458
        );
459
    }
110✔
460
  }
110✔
461

462
  async duplicate(baseId: string, nodeId: string, ro: IDuplicateBaseNodeRo) {
135✔
463
    this.setIgnoreBaseNodeListener();
5✔
464

465
    const anchor = await this.prismaService.baseNode
5✔
466
      .findFirstOrThrow({
5✔
467
        where: { baseId, id: nodeId },
5✔
468
      })
5✔
469
      .catch(() => {
5✔
470
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
471
          localization: {
×
472
            i18nKey: 'httpErrors.baseNode.notFound',
×
473
          },
×
474
        });
×
475
      });
×
476
    const { resourceType, resourceId } = anchor;
5✔
477

478
    if (resourceType === BaseNodeResourceType.Folder) {
5✔
479
      throw new CustomHttpException('Cannot duplicate folder', HttpErrorCode.VALIDATION_ERROR, {
1✔
480
        localization: {
1✔
481
          i18nKey: 'httpErrors.baseNode.cannotDuplicateFolder',
1✔
482
        },
1✔
483
      });
1✔
484
    }
1✔
485

486
    const resource = await this.duplicateResource(
4✔
487
      baseId,
4✔
488
      resourceType as BaseNodeResourceType,
4✔
489
      resourceId,
4✔
490
      ro
4✔
491
    );
492
    const { entry } = await this.prismaService.$tx(async (prisma) => {
4✔
493
      const maxOrder = await this.getMaxOrder(baseId, anchor.parentId);
4✔
494
      const newNodeId = generateBaseNodeId();
4✔
495
      const entry = await prisma.baseNode.create({
4✔
496
        data: {
4✔
497
          id: newNodeId,
4✔
498
          baseId,
4✔
499
          resourceType,
4✔
500
          resourceId: resource.id,
4✔
501
          order: maxOrder + 1,
4✔
502
          parentId: anchor.parentId,
4✔
503
          createdBy: this.userId,
4✔
504
        },
4✔
505
        select: this.getSelect(),
4✔
506
      });
4✔
507

508
      await updateOrder({
4✔
509
        query: baseId,
4✔
510
        position: 'after',
4✔
511
        item: entry,
4✔
512
        anchorItem: anchor,
4✔
513
        getNextItem: async (whereOrder, align) => {
4✔
514
          return prisma.baseNode.findFirst({
4✔
515
            where: {
4✔
516
              baseId,
4✔
517
              parentId: anchor.parentId,
4✔
518
              order: whereOrder,
4✔
519
              id: { not: newNodeId },
4✔
520
            },
4✔
521
            select: { order: true, id: true },
4✔
522
            orderBy: { order: align },
4✔
523
          });
4✔
524
        },
4✔
525
        update: async (_, id, data) => {
4✔
526
          await prisma.baseNode.update({
4✔
527
            where: { id },
4✔
528
            data: { parentId: anchor.parentId, order: data.newOrder },
4✔
529
          });
4✔
530
        },
4✔
531
        shuffle: async () => {
4✔
532
          await this.shuffleOrders(baseId, anchor.parentId);
×
533
        },
×
534
      });
4✔
535

536
      return {
4✔
537
        entry,
4✔
538
      };
4✔
539
    });
4✔
540

541
    const vo = await this.entry2vo(entry, omit(resource, 'id'));
4✔
542
    this.presenceHandler(baseId, (presence) => {
4✔
543
      presence.submit({
4✔
544
        event: 'create',
4✔
545
        data: { ...vo },
4✔
546
      });
4✔
547
    });
4✔
548
    return vo;
4✔
549
  }
4✔
550

551
  protected async duplicateResource(
135✔
552
    baseId: string,
4✔
553
    type: BaseNodeResourceType,
4✔
554
    id: string,
4✔
555
    duplicateRo: IDuplicateBaseNodeRo
4✔
556
  ): Promise<IBaseNodeResourceMetaWithId> {
4✔
557
    switch (type) {
4✔
558
      case BaseNodeResourceType.Table: {
4✔
559
        const table = await this.tableDuplicateService.duplicateTable(
2✔
560
          baseId,
2✔
561
          id,
2✔
562
          duplicateRo as IDuplicateTableRo
2✔
563
        );
564

565
        return {
2✔
566
          id: table.id,
2✔
567
          name: table.name,
2✔
568
          icon: table.icon ?? undefined,
2✔
569
          defaultViewId: table.defaultViewId,
2✔
570
        };
2✔
571
      }
2✔
572
      case BaseNodeResourceType.Dashboard: {
4✔
573
        const dashboard = await this.dashboardService.duplicateDashboard(
2✔
574
          baseId,
2✔
575
          id,
2✔
576
          duplicateRo as IDuplicateDashboardRo
2✔
577
        );
578
        return { id: dashboard.id, name: dashboard.name };
2✔
579
      }
2✔
580
      default:
4!
581
        throw new CustomHttpException(
×
582
          `Invalid resource type ${type}`,
×
583
          HttpErrorCode.VALIDATION_ERROR,
×
584
          {
×
585
            localization: {
×
586
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
×
587
            },
×
588
          }
×
589
        );
590
    }
4✔
591
  }
4✔
592

593
  async update(baseId: string, nodeId: string, ro: IUpdateBaseNodeRo) {
135✔
594
    this.setIgnoreBaseNodeListener();
7✔
595

596
    const node = await this.prismaService.baseNode
7✔
597
      .findFirstOrThrow({
7✔
598
        where: { baseId, id: nodeId },
7✔
599
        select: this.getSelect(),
7✔
600
      })
7✔
601
      .catch(() => {
7✔
602
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
603
          localization: {
×
604
            i18nKey: 'httpErrors.baseNode.notFound',
×
605
          },
×
606
        });
×
607
      });
×
608

609
    await this.updateResource(
7✔
610
      baseId,
7✔
611
      node.resourceType as BaseNodeResourceType,
7✔
612
      node.resourceId,
7✔
613
      ro
7✔
614
    );
615

616
    const vo = await this.entry2vo(node);
7✔
617
    this.presenceHandler(baseId, (presence) => {
7✔
618
      presence.submit({
7✔
619
        event: 'update',
7✔
620
        data: { ...vo },
7✔
621
      });
7✔
622
    });
7✔
623
    return vo;
7✔
624
  }
7✔
625

626
  protected async updateResource(
135✔
627
    baseId: string,
7✔
628
    type: BaseNodeResourceType,
7✔
629
    id: string,
7✔
630
    updateRo: IUpdateBaseNodeRo
7✔
631
  ): Promise<void> {
7✔
632
    const { name, icon } = updateRo;
7✔
633
    switch (type) {
7✔
634
      case BaseNodeResourceType.Folder:
7!
635
        if (name) {
×
636
          await this.baseNodeFolderService.renameFolder(baseId, id, { name });
×
637
        }
×
638
        break;
×
639
      case BaseNodeResourceType.Table:
7✔
640
        if (name) {
7✔
641
          await this.tableOpenApiService.updateName(baseId, id, name);
5✔
642
        }
5✔
643
        if (icon) {
7✔
644
          await this.tableOpenApiService.updateIcon(baseId, id, icon);
3✔
645
        }
3✔
646
        break;
7✔
647
      case BaseNodeResourceType.Dashboard:
7!
648
        if (name) {
×
649
          await this.dashboardService.renameDashboard(baseId, id, name);
×
650
        }
×
651
        break;
×
652
      default:
7!
653
        throw new CustomHttpException(
×
654
          `Invalid resource type ${type}`,
×
655
          HttpErrorCode.VALIDATION_ERROR,
×
656
          {
×
657
            localization: {
×
658
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
×
659
            },
×
660
          }
×
661
        );
662
    }
7✔
663
  }
7✔
664

665
  async delete(baseId: string, nodeId: string, permanent?: boolean) {
135✔
666
    this.setIgnoreBaseNodeListener();
99✔
667

668
    const node = await this.prismaService.baseNode
99✔
669
      .findFirstOrThrow({
99✔
670
        where: { baseId, id: nodeId },
99✔
671
        select: { resourceType: true, resourceId: true },
99✔
672
      })
99✔
673
      .catch(() => {
99✔
674
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
675
          localization: {
×
676
            i18nKey: 'httpErrors.baseNode.notFound',
×
677
          },
×
678
        });
×
679
      });
×
680
    if (node.resourceType === BaseNodeResourceType.Folder) {
99✔
681
      const children = await this.prismaService.baseNode.findMany({
66✔
682
        where: { baseId, parentId: nodeId },
66✔
683
      });
66✔
684
      if (children.length > 0) {
66✔
685
        throw new CustomHttpException(
2✔
686
          'Cannot delete folder because it is not empty',
2✔
687
          HttpErrorCode.VALIDATION_ERROR,
2✔
688
          {
2✔
689
            localization: {
2✔
690
              i18nKey: 'httpErrors.baseNode.cannotDeleteEmptyFolder',
2✔
691
            },
2✔
692
          }
2✔
693
        );
694
      }
2✔
695
    }
66✔
696

697
    await this.deleteResource(
97✔
698
      baseId,
97✔
699
      node.resourceType as BaseNodeResourceType,
97✔
700
      node.resourceId,
97✔
701
      permanent
97✔
702
    );
703
    await this.prismaService.baseNode.delete({
97✔
704
      where: { id: nodeId },
97✔
705
    });
97✔
706

707
    this.presenceHandler(baseId, (presence) => {
97✔
708
      presence.submit({
97✔
709
        event: 'delete',
97✔
710
        data: { id: nodeId },
97✔
711
      });
97✔
712
    });
97✔
713
    return node;
97✔
714
  }
97✔
715

716
  protected async deleteResource(
135✔
717
    baseId: string,
97✔
718
    type: BaseNodeResourceType,
97✔
719
    id: string,
97✔
720
    permanent?: boolean
97✔
721
  ) {
97✔
722
    switch (type) {
97✔
723
      case BaseNodeResourceType.Folder:
97✔
724
        await this.baseNodeFolderService.deleteFolder(baseId, id);
64✔
725
        break;
64✔
726
      case BaseNodeResourceType.Table:
97✔
727
        if (permanent) {
25!
728
          await this.tableOpenApiService.permanentDeleteTables(baseId, [id]);
×
729
        } else {
25✔
730
          await this.tableOpenApiService.deleteTable(baseId, id);
25✔
731
        }
25✔
732
        break;
25✔
733
      case BaseNodeResourceType.Dashboard:
97✔
734
        await this.dashboardService.deleteDashboard(baseId, id);
8✔
735
        break;
8✔
736
      default:
97!
737
        throw new CustomHttpException(
×
738
          `Invalid resource type ${type}`,
×
739
          HttpErrorCode.VALIDATION_ERROR,
×
740
          {
×
741
            localization: {
×
742
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
×
743
            },
×
744
          }
×
745
        );
746
    }
97✔
747
  }
97✔
748

749
  async move(baseId: string, nodeId: string, ro: IMoveBaseNodeRo): Promise<IBaseNodeVo> {
135✔
750
    this.setIgnoreBaseNodeListener();
20✔
751

752
    const { parentId, anchorId, position } = ro;
20✔
753

754
    const node = await this.prismaService.baseNode
20✔
755
      .findFirstOrThrow({
20✔
756
        where: { baseId, id: nodeId },
20✔
757
      })
20✔
758
      .catch(() => {
20✔
759
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
760
          localization: {
×
761
            i18nKey: 'httpErrors.baseNode.notFound',
×
762
          },
×
763
        });
×
764
      });
×
765

766
    if (isString(parentId) && isString(anchorId)) {
20!
767
      throw new CustomHttpException(
×
768
        'Only one of parentId or anchorId must be provided',
×
769
        HttpErrorCode.VALIDATION_ERROR,
×
770
        {
×
771
          localization: {
×
772
            i18nKey: 'httpErrors.baseNode.onlyOneOfParentIdOrAnchorIdRequired',
×
773
          },
×
774
        }
×
775
      );
776
    }
×
777

778
    if (parentId === nodeId) {
20!
779
      throw new CustomHttpException('Cannot move node to itself', HttpErrorCode.VALIDATION_ERROR, {
1✔
780
        localization: {
1✔
781
          i18nKey: 'httpErrors.baseNode.cannotMoveToItself',
1✔
782
        },
1✔
783
      });
1✔
784
    }
1✔
785

786
    if (anchorId === nodeId) {
20!
787
      throw new CustomHttpException(
×
788
        'Cannot move node to its own child (circular reference)',
×
789
        HttpErrorCode.VALIDATION_ERROR,
×
790
        {
×
791
          localization: {
×
792
            i18nKey: 'httpErrors.baseNode.cannotMoveToCircularReference',
×
793
          },
×
794
        }
×
795
      );
796
    }
✔
797

798
    let newNode: IBaseNodeEntry;
19✔
799
    if (anchorId) {
20!
800
      newNode = await this.moveNodeTo(baseId, node.id, { anchorId, position });
7✔
801
    } else if (parentId === null) {
20!
802
      newNode = await this.moveNodeToRoot(baseId, node.id);
2✔
803
    } else if (parentId) {
12✔
804
      newNode = await this.moveNodeToFolder(baseId, node.id, parentId);
10✔
805
    } else {
10!
806
      throw new CustomHttpException(
×
807
        'At least one of parentId or anchorId must be provided',
×
808
        HttpErrorCode.VALIDATION_ERROR,
×
809
        {
×
810
          localization: {
×
811
            i18nKey: 'httpErrors.baseNode.anchorIdOrParentIdRequired',
×
812
          },
×
813
        }
×
814
      );
815
    }
✔
816

817
    const vo = await this.entry2vo(newNode);
14✔
818
    this.presenceHandler(baseId, (presence) => {
14✔
819
      presence.submit({
14✔
820
        event: 'update',
14✔
821
        data: { ...vo },
14✔
822
      });
14✔
823
    });
14✔
824

825
    return vo;
14✔
826
  }
14✔
827

828
  private async moveNodeToRoot(baseId: string, nodeId: string) {
135✔
829
    return this.prismaService.$tx(async (prisma) => {
2✔
830
      const maxOrder = await this.getMaxOrder(baseId);
2✔
831
      return prisma.baseNode.update({
2✔
832
        where: { id: nodeId },
2✔
833
        select: this.getSelect(),
2✔
834
        data: {
2✔
835
          parentId: null,
2✔
836
          order: maxOrder + 1,
2✔
837
          lastModifiedBy: this.userId,
2✔
838
        },
2✔
839
      });
2✔
840
    });
2✔
841
  }
2✔
842

843
  private async moveNodeToFolder(baseId: string, nodeId: string, parentId: string) {
135✔
844
    return this.prismaService.$tx(async (prisma) => {
10✔
845
      const node = await prisma.baseNode
10✔
846
        .findFirstOrThrow({
10✔
847
          where: { baseId, id: nodeId },
10✔
848
        })
10✔
849
        .catch(() => {
10✔
850
          throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
851
            localization: {
×
852
              i18nKey: 'httpErrors.baseNode.notFound',
×
853
            },
×
854
          });
×
855
        });
×
856

857
      const parentNode = await prisma.baseNode
10✔
858
        .findFirstOrThrow({
10✔
859
          where: { baseId, id: parentId },
10✔
860
        })
10✔
861
        .catch(() => {
10✔
862
          throw new CustomHttpException(`Parent ${parentId} not found`, HttpErrorCode.NOT_FOUND, {
×
863
            localization: {
×
864
              i18nKey: 'httpErrors.baseNode.parentNotFound',
×
865
            },
×
866
          });
×
867
        });
×
868

869
      if (parentNode.resourceType !== BaseNodeResourceType.Folder) {
10!
870
        throw new CustomHttpException(
1✔
871
          `Parent ${parentId} is not a folder`,
1✔
872
          HttpErrorCode.VALIDATION_ERROR,
1✔
873
          {
1✔
874
            localization: {
1✔
875
              i18nKey: 'httpErrors.baseNode.parentIsNotFolder',
1✔
876
            },
1✔
877
          }
1✔
878
        );
879
      }
1✔
880

881
      if (node.resourceType === BaseNodeResourceType.Folder && parentId) {
10!
882
        await this.assertFolderDepth(baseId, parentId);
3✔
883
      }
1✔
884

885
      // Check for circular reference
7✔
886
      const isCircular = await this.isCircularReference(baseId, nodeId, parentId);
7✔
887
      if (isCircular) {
10!
888
        throw new CustomHttpException(
×
889
          'Cannot move node to its own child (circular reference)',
×
890
          HttpErrorCode.VALIDATION_ERROR,
×
891
          {
×
892
            localization: {
×
893
              i18nKey: 'httpErrors.baseNode.circularReference',
×
894
            },
×
895
          }
×
896
        );
897
      }
✔
898

899
      const maxOrder = await this.getMaxOrder(baseId);
7✔
900
      return prisma.baseNode.update({
7✔
901
        where: { id: nodeId },
7✔
902
        select: this.getSelect(),
7✔
903
        data: {
7✔
904
          parentId,
7✔
905
          order: maxOrder + 1,
7✔
906
          lastModifiedBy: this.userId,
7✔
907
        },
7✔
908
      });
7✔
909
    });
7✔
910
  }
10✔
911

912
  private async moveNodeTo(
135✔
913
    baseId: string,
7✔
914
    nodeId: string,
7✔
915
    ro: Pick<IMoveBaseNodeRo, 'anchorId' | 'position'>
7✔
916
  ): Promise<IBaseNodeEntry> {
7✔
917
    const { anchorId, position } = ro;
7✔
918
    return this.prismaService.$tx(async (prisma) => {
7✔
919
      const node = await prisma.baseNode
7✔
920
        .findFirstOrThrow({
7✔
921
          where: { baseId, id: nodeId },
7✔
922
        })
7✔
923
        .catch(() => {
7✔
924
          throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
925
            localization: {
×
926
              i18nKey: 'httpErrors.baseNode.notFound',
×
927
            },
×
928
          });
×
929
        });
×
930

931
      const anchor = await prisma.baseNode
7✔
932
        .findFirstOrThrow({
7✔
933
          where: { baseId, id: anchorId },
7✔
934
        })
7✔
935
        .catch(() => {
7✔
936
          throw new CustomHttpException(`Anchor ${anchorId} not found`, HttpErrorCode.NOT_FOUND, {
1✔
937
            localization: {
1✔
938
              i18nKey: 'httpErrors.baseNode.anchorNotFound',
1✔
939
            },
1✔
940
          });
1✔
941
        });
1✔
942

943
      if (node.resourceType === BaseNodeResourceType.Folder && anchor.parentId) {
7✔
944
        await this.assertFolderDepth(baseId, anchor.parentId);
4✔
945
      }
3✔
946

947
      await updateOrder({
5✔
948
        query: baseId,
5✔
949
        position: position ?? 'after',
7!
950
        item: node,
7✔
951
        anchorItem: anchor,
7✔
952
        getNextItem: async (whereOrder, align) => {
7✔
953
          return prisma.baseNode.findFirst({
5✔
954
            where: {
5✔
955
              baseId,
5✔
956
              parentId: anchor.parentId,
5✔
957
              order: whereOrder,
5✔
958
            },
5✔
959
            select: { order: true, id: true },
5✔
960
            orderBy: { order: align },
5✔
961
          });
5✔
962
        },
5✔
963
        update: async (_, id, data) => {
7✔
964
          await prisma.baseNode.update({
5✔
965
            where: { id },
5✔
966
            data: { parentId: anchor.parentId, order: data.newOrder },
5✔
967
          });
5✔
968
        },
5✔
969
        shuffle: async () => {
7✔
970
          await this.shuffleOrders(baseId, anchor.parentId);
×
971
        },
×
972
      });
7✔
973

974
      return prisma.baseNode.findFirstOrThrow({
5✔
975
        where: { baseId, id: nodeId },
5✔
976
        select: this.getSelect(),
5✔
977
      });
5✔
978
    });
5✔
979
  }
7✔
980

981
  async getMaxOrder(baseId: string, parentId?: string | null) {
135✔
982
    const prisma = this.prismaService.txClient();
120✔
983
    const aggregate = await prisma.baseNode.aggregate({
120✔
984
      where: { baseId, parentId },
120✔
985
      _max: { order: true },
120✔
986
    });
120✔
987

988
    return aggregate._max.order ?? 0;
120✔
989
  }
120✔
990

991
  private async shuffleOrders(baseId: string, parentId: string | null) {
135✔
992
    const prisma = this.prismaService.txClient();
×
993
    const siblings = await prisma.baseNode.findMany({
×
994
      where: { baseId, parentId },
×
995
      orderBy: { order: 'asc' },
×
996
    });
×
997

998
    for (const [index, sibling] of siblings.entries()) {
×
999
      await prisma.baseNode.update({
×
1000
        where: { id: sibling.id },
×
1001
        data: { order: index + 10, lastModifiedBy: this.userId },
×
1002
      });
×
1003
    }
×
1004
  }
×
1005

1006
  private async getParentNodeOrThrow(id: string) {
135✔
1007
    const entry = await this.prismaService.baseNode.findFirst({
27✔
1008
      where: { id },
27✔
1009
      select: {
27✔
1010
        id: true,
27✔
1011
        parentId: true,
27✔
1012
        resourceType: true,
27✔
1013
        resourceId: true,
27✔
1014
      },
27✔
1015
    });
27✔
1016
    if (!entry) {
27✔
1017
      throw new CustomHttpException('Base node not found', HttpErrorCode.NOT_FOUND, {
1✔
1018
        localization: {
1✔
1019
          i18nKey: 'httpErrors.baseNode.notFound',
1✔
1020
        },
1✔
1021
      });
1✔
1022
    }
1✔
1023
    return entry;
26✔
1024
  }
26✔
1025

1026
  private async assertFolderDepth(baseId: string, id: string) {
135✔
1027
    const folderDepth = await this.getFolderDepth(baseId, id);
28✔
1028
    if (folderDepth >= maxFolderDepth) {
28✔
1029
      throw new CustomHttpException('Folder depth limit exceeded', HttpErrorCode.VALIDATION_ERROR, {
4✔
1030
        localization: {
4✔
1031
          i18nKey: 'httpErrors.baseNode.folderDepthLimitExceeded',
4✔
1032
        },
4✔
1033
      });
4✔
1034
    }
4✔
1035
  }
28✔
1036

1037
  private async getFolderDepth(baseId: string, id: string) {
135✔
1038
    const prisma = this.prismaService.txClient();
28✔
1039
    const allFolders = await prisma.baseNode.findMany({
28✔
1040
      where: { baseId, resourceType: BaseNodeResourceType.Folder },
28✔
1041
      select: { id: true, parentId: true },
28✔
1042
    });
28✔
1043

1044
    let depth = 0;
28✔
1045
    if (allFolders.length === 0) {
28!
1046
      return depth;
×
1047
    }
×
1048

1049
    const folderMap = keyBy(allFolders, 'id');
28✔
1050
    let current = id;
28✔
1051
    while (current) {
28✔
1052
      depth++;
32✔
1053
      const folder = folderMap[current];
32✔
1054
      if (!folder) {
32!
1055
        throw new CustomHttpException('Folder not found', HttpErrorCode.NOT_FOUND, {
×
1056
          localization: {
×
1057
            i18nKey: 'httpErrors.baseNode.folderNotFound',
×
1058
          },
×
1059
        });
×
1060
      }
×
1061
      if (folder.parentId === id) {
32!
1062
        throw new CustomHttpException(
×
1063
          'A folder cannot be its own parent',
×
1064
          HttpErrorCode.VALIDATION_ERROR,
×
1065
          {
×
1066
            localization: {
×
1067
              i18nKey: 'httpErrors.baseNode.circularReference',
×
1068
            },
×
1069
          }
×
1070
        );
1071
      }
×
1072
      current = folder.parentId ?? '';
32✔
1073
    }
32✔
1074
    return depth;
28✔
1075
  }
28✔
1076

1077
  private async isCircularReference(
135✔
1078
    baseId: string,
7✔
1079
    nodeId: string,
7✔
1080
    parentId: string
7✔
1081
  ): Promise<boolean> {
7✔
1082
    const knex = this.knex;
7✔
1083

1084
    // Non-recursive query: Start with the parent node
7✔
1085
    const nonRecursiveQuery = knex
7✔
1086
      .select('id', 'parent_id', 'base_id')
7✔
1087
      .from('base_node')
7✔
1088
      .where('id', parentId)
7✔
1089
      .andWhere('base_id', baseId);
7✔
1090

1091
    // Recursive query: Traverse up the parent chain
7✔
1092
    const recursiveQuery = knex
7✔
1093
      .select('bn.id', 'bn.parent_id', 'bn.base_id')
7✔
1094
      .from('base_node as bn')
7✔
1095
      .innerJoin('ancestors as a', function () {
7✔
1096
        // Join condition: bn.id = a.parent_id (get parent of current ancestor)
7✔
1097
        this.on('bn.id', '=', 'a.parent_id').andOn('bn.base_id', '=', knex.raw('?', [baseId]));
7✔
1098
      });
7✔
1099

1100
    // Combine non-recursive and recursive queries
7✔
1101
    const cteQuery = nonRecursiveQuery.union(recursiveQuery);
7✔
1102

1103
    // Build final query with recursive CTE
7✔
1104
    const finalQuery = knex
7✔
1105
      .withRecursive('ancestors', ['id', 'parent_id', 'base_id'], cteQuery)
7✔
1106
      .select('id')
7✔
1107
      .from('ancestors')
7✔
1108
      .where('id', nodeId)
7✔
1109
      .limit(1)
7✔
1110
      .toQuery();
7✔
1111

1112
    // Execute query
7✔
1113
    const result = await this.prismaService
7✔
1114
      .txClient()
7✔
1115
      .$queryRawUnsafe<Array<{ id: string }>>(finalQuery);
7✔
1116

1117
    return result.length > 0;
7✔
1118
  }
7✔
1119

1120
  async batchUpdateBaseNodes(data: { id: string; values: { [key: string]: unknown } }[]) {
135✔
1121
    const sql = buildBatchUpdateSql(this.knex, data);
×
1122
    if (!sql) {
×
1123
      return;
×
1124
    }
×
1125
    await this.prismaService.txClient().$executeRawUnsafe(sql);
×
1126
  }
×
1127

1128
  private presenceHandler<
135✔
1129
    T =
1130
      | IBaseNodePresenceCreatePayload
1131
      | IBaseNodePresenceUpdatePayload
1132
      | IBaseNodePresenceDeletePayload,
1133
  >(baseId: string, handler: (presence: LocalPresence<T>) => void) {
229✔
1134
    this.performanceCacheService.del(generateBaseNodeListCacheKey(baseId));
229✔
1135
    // Skip if ShareDB connection is already closed (e.g., during shutdown)
229✔
1136
    if (this.shareDbService.shareDbAdapter.closed) {
229!
1137
      this.logger.error('ShareDB connection is already closed, presence handler skipped');
×
1138
      return;
×
1139
    }
×
1140
    presenceHandler(baseId, this.shareDbService, handler);
229✔
1141
  }
229✔
1142
}
135✔
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