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

teableio / teable / 19932630374

04 Dec 2025 02:34PM UTC coverage: 71.844% (+0.1%) from 71.723%
19932630374

Pull #2168

github

web-flow
Merge b9b2f5244 into b64c80425
Pull Request #2168: feat: base node

22785 of 25510 branches covered (89.32%)

1808 of 2360 new or added lines in 32 files covered. (76.61%)

3 existing lines in 2 files now uncovered.

57410 of 79909 relevant lines covered (71.84%)

4245.61 hits per line

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

77.69
/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 { OnEvent } from '@nestjs/event-emitter';
4
import {
5
  ANONYMOUS_USER_ID,
6
  generateBaseNodeId,
7
  getBaseNodeChannel,
8
  HttpErrorCode,
9
} from '@teable/core';
10
import type { Prisma } from '@teable/db-main-prisma';
11
import { PrismaService } from '@teable/db-main-prisma';
12
import type {
13
  IBaseNodePresenceCreatePayload,
14
  IBaseNodePresenceDeletePayload,
15
  IMoveBaseNodeRo,
16
  IBaseNodeVo,
17
  IBaseNodeTreeVo,
18
  IBaseNodePresenceUpdatePayload,
19
  ICreateBaseNodeRo,
20
  IDuplicateBaseNodeRo,
21
  IDuplicateTableRo,
22
  ICreateDashboardRo,
23
  ICreateFolderNodeRo,
24
  IDuplicateDashboardRo,
25
  IUpdateBaseNodeRo,
26
  IBaseNodePresenceFlushPayload,
27
  IBaseNodeResourceMeta,
28
  IBaseNodeResourceMetaWithId,
29
  ICreateTableRo,
30
} from '@teable/openapi';
31
import { BaseNodeResourceType } from '@teable/openapi';
32
import { Knex } from 'knex';
33
import { isString, keyBy, omit, snakeCase } from 'lodash';
34
import { InjectModel } from 'nest-knexjs';
35
import { ClsService } from 'nestjs-cls';
36
import type { LocalPresence } from 'sharedb/lib/client';
37
import { CustomHttpException } from '../../custom.exception';
38
import type {
39
  BaseFolderUpdateEvent,
40
  BaseFolderDeleteEvent,
41
  TableDeleteEvent,
42
  TableUpdateEvent,
43
  TableCreateEvent,
44
  BaseFolderCreateEvent,
45
} from '../../event-emitter/events';
46
import type {
47
  AppCreateEvent,
48
  AppDeleteEvent,
49
  AppUpdateEvent,
50
} from '../../event-emitter/events/app/app.event';
51
import type { BaseDeleteEvent } from '../../event-emitter/events/base/base.event';
52
import type {
53
  DashboardCreateEvent,
54
  DashboardDeleteEvent,
55
  DashboardUpdateEvent,
56
} from '../../event-emitter/events/dashboard/dashboard.event';
57
import { Events } from '../../event-emitter/events/event.enum';
58
import type {
59
  WorkflowCreateEvent,
60
  WorkflowDeleteEvent,
61
  WorkflowUpdateEvent,
62
} from '../../event-emitter/events/workflow/workflow.event';
63
import { generateBaseNodeListCacheKey } from '../../performance-cache/generate-keys';
64
import { PerformanceCacheService } from '../../performance-cache/service';
65
import type { IPerformanceCacheStore } from '../../performance-cache/types';
66
import { ShareDbService } from '../../share-db/share-db.service';
67
import type { IClsStore } from '../../types/cls';
68
import { updateOrder } from '../../utils/update-order';
69
import { DashboardService } from '../dashboard/dashboard.service';
70
import { TableOpenApiService } from '../table/open-api/table-open-api.service';
71
import { prepareCreateTableRo } from '../table/open-api/table.pipe.helper';
72
import { TableDuplicateService } from '../table/table-duplicate.service';
73
import { BaseNodeFolderService } from './folder/base-node-folder.service';
74

75
type IResourceCreateEvent =
76
  | BaseFolderCreateEvent
77
  | TableCreateEvent
78
  | WorkflowCreateEvent
79
  | DashboardCreateEvent
80
  | AppCreateEvent;
81

82
type IResourceDeleteEvent =
83
  | BaseDeleteEvent
84
  | BaseFolderDeleteEvent
85
  | TableDeleteEvent
86
  | WorkflowDeleteEvent
87
  | DashboardDeleteEvent
88
  | AppDeleteEvent;
89

90
type IResourceUpdateEvent =
91
  | BaseFolderUpdateEvent
92
  | TableUpdateEvent
93
  | WorkflowUpdateEvent
94
  | DashboardUpdateEvent
95
  | AppUpdateEvent;
96

97
type IBaseNodeEntry = {
98
  id: string;
99
  baseId: string;
100
  parentId: string | null;
101
  resourceType: string;
102
  resourceId: string;
103
  order: number;
104
  children: { id: string; order: number }[];
105
  parent: { id: string } | null;
106
};
107

108
// max depth is maxFolderDepth + 1
5✔
109
const maxFolderDepth = 2;
5✔
110

111
@Injectable()
112
export class BaseNodeService {
5✔
113
  private readonly logger = new Logger(BaseNodeService.name);
117✔
114
  constructor(
117✔
115
    private readonly performanceCacheService: PerformanceCacheService<IPerformanceCacheStore>,
117✔
116
    private readonly prismaService: PrismaService,
117✔
117
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
117✔
118
    private readonly cls: ClsService<IClsStore>,
117✔
119
    private readonly shareDbService: ShareDbService,
117✔
120
    private readonly baseNodeFolderService: BaseNodeFolderService,
117✔
121
    private readonly tableOpenApiService: TableOpenApiService,
117✔
122
    private readonly tableDuplicateService: TableDuplicateService,
117✔
123
    private readonly dashboardService: DashboardService
117✔
124
  ) {}
117✔
125

126
  private get userId() {
117✔
127
    return this.cls.get('user.id');
120✔
128
  }
120✔
129

130
  private getSelect() {
117✔
131
    return {
156✔
132
      id: true,
156✔
133
      baseId: true,
156✔
134
      parentId: true,
156✔
135
      resourceType: true,
156✔
136
      resourceId: true,
156✔
137
      order: true,
156✔
138
      children: {
156✔
139
        select: { id: true, order: true },
156✔
140
        orderBy: { order: 'asc' as const },
156✔
141
      },
156✔
142
      parent: {
156✔
143
        select: { id: true },
156✔
144
      },
156✔
145
    };
156✔
146
  }
156✔
147

148
  private async entry2vo(
117✔
149
    entry: IBaseNodeEntry,
140✔
150
    resource?: IBaseNodeResourceMeta
140✔
151
  ): Promise<IBaseNodeVo> {
140✔
152
    if (resource) {
140✔
153
      return {
111✔
154
        ...entry,
111✔
155
        resourceType: entry.resourceType as BaseNodeResourceType,
111✔
156
        resourceMeta: resource,
111✔
157
      };
111✔
158
    }
111✔
159
    const { resourceType, resourceId } = entry;
29✔
160
    const list = await this.getNodeResource(entry.baseId, resourceType as BaseNodeResourceType, [
29✔
161
      resourceId,
29✔
162
    ]);
29✔
163
    const resourceMeta = list[0];
29✔
164
    return {
29✔
165
      ...entry,
29✔
166
      resourceType: resourceType as BaseNodeResourceType,
29✔
167
      resourceMeta: omit(resourceMeta, 'id'),
29✔
168
    };
29✔
169
  }
29✔
170

171
  private presenceHandler<
117✔
172
    T =
173
      | IBaseNodePresenceFlushPayload
174
      | IBaseNodePresenceCreatePayload
175
      | IBaseNodePresenceUpdatePayload
176
      | IBaseNodePresenceDeletePayload,
177
  >(baseId: string, handler: (presence: LocalPresence<T>) => void) {
4,893✔
178
    this.performanceCacheService.del(generateBaseNodeListCacheKey(baseId));
4,893✔
179
    // Skip if ShareDB connection is already closed (e.g., during shutdown)
4,893✔
180
    if (this.shareDbService.shareDbAdapter.closed) {
4,893!
NEW
181
      this.logger.error('ShareDB connection is already closed, presence handler skipped');
×
NEW
182
      return;
×
NEW
183
    }
×
184
    const channel = getBaseNodeChannel(baseId);
4,893✔
185
    const presence = this.shareDbService.connect().getPresence(channel);
4,893✔
186
    const localPresence = presence.create(channel);
4,893✔
187
    handler(localPresence);
4,893✔
188
    localPresence.destroy();
4,893✔
189
  }
4,893✔
190

191
  protected getTableResources(baseId: string, ids?: string[]) {
117✔
192
    return this.prismaService.tableMeta.findMany({
33✔
193
      where: { baseId, id: { in: ids ? ids : undefined }, deletedTime: null },
33✔
194
      select: {
33✔
195
        id: true,
33✔
196
        name: true,
33✔
197
        icon: true,
33✔
198
      },
33✔
199
    });
33✔
200
  }
33✔
201

202
  protected getDashboardResources(baseId: string, ids?: string[]) {
117✔
203
    return this.prismaService.dashboard.findMany({
19✔
204
      where: { baseId, id: { in: ids ? ids : undefined } },
19✔
205
      select: {
19✔
206
        id: true,
19✔
207
        name: true,
19✔
208
      },
19✔
209
    });
19✔
210
  }
19✔
211

212
  protected getFolderResources(baseId: string, ids?: string[]) {
117✔
213
    return this.prismaService.baseNodeFolder.findMany({
25✔
214
      where: { baseId, id: { in: ids ? ids : undefined } },
25✔
215
      select: {
25✔
216
        id: true,
25✔
217
        name: true,
25✔
218
      },
25✔
219
    });
25✔
220
  }
25✔
221

222
  protected async getNodeResource(
117✔
223
    baseId: string,
77✔
224
    type: BaseNodeResourceType,
77✔
225
    ids?: string[]
77✔
226
  ): Promise<IBaseNodeResourceMetaWithId[]> {
77✔
227
    switch (type) {
77✔
228
      case BaseNodeResourceType.Folder:
77✔
229
        return this.getFolderResources(baseId, ids);
25✔
230
      case BaseNodeResourceType.Table:
77✔
231
        return this.getTableResources(baseId, ids);
33✔
232
      case BaseNodeResourceType.Dashboard:
77✔
233
        return this.getDashboardResources(baseId, ids);
19✔
234
      default:
77✔
NEW
235
        throw new CustomHttpException(
×
NEW
236
          `Invalid resource type ${type}`,
×
NEW
237
          HttpErrorCode.VALIDATION_ERROR,
×
NEW
238
          {
×
NEW
239
            localization: {
×
NEW
240
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
×
NEW
241
            },
×
NEW
242
          }
×
243
        );
244
    }
77✔
245
  }
77✔
246

247
  protected getResourceTypes(): BaseNodeResourceType[] {
117✔
248
    return [
16✔
249
      BaseNodeResourceType.Folder,
16✔
250
      BaseNodeResourceType.Table,
16✔
251
      BaseNodeResourceType.Dashboard,
16✔
252
    ];
16✔
253
  }
16✔
254

255
  async prepareNodeList(baseId: string): Promise<IBaseNodeVo[]> {
117✔
256
    const resourceTypes = this.getResourceTypes();
16✔
257
    const resourceResults = await Promise.all(
16✔
258
      resourceTypes.map((type) => this.getNodeResource(baseId, type))
16✔
259
    );
260

261
    const resources = resourceResults.flatMap((list, index) =>
16✔
262
      list.map((r) => ({ ...r, type: resourceTypes[index] }))
48✔
263
    );
264

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

268
    const nodes = await this.prismaService.baseNode.findMany({
16✔
269
      where: { baseId },
16✔
270
      select: this.getSelect(),
16✔
271
      orderBy: { order: 'asc' },
16✔
272
    });
16✔
273

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

276
    const toCreate = resources.filter((r) => !nodeKeys.has(`${r.type}_${r.id}`));
16✔
277
    const toDelete = nodes.filter((n) => !resourceKeys.has(`${n.resourceType}_${n.resourceId}`));
16✔
278
    const validParentIds = new Set(nodes.filter((n) => !toDelete.includes(n)).map((n) => n.id));
16✔
279
    const orphans = nodes.filter(
16✔
280
      (n) => n.parentId && !validParentIds.has(n.parentId) && !toDelete.includes(n)
16✔
281
    );
282

283
    if (toCreate.length === 0 && toDelete.length === 0 && orphans.length === 0) {
16✔
284
      return nodes.map((entry) => {
16✔
285
        const key = `${entry.resourceType}_${entry.resourceId}`;
64✔
286
        const resource = resourceMap[key];
64✔
287
        return {
64✔
288
          ...entry,
64✔
289
          resourceType: entry.resourceType as BaseNodeResourceType,
64✔
290
          resourceMeta: omit(resource, 'id'),
64✔
291
        };
64✔
292
      });
64✔
293
    }
16✔
294

NEW
295
    const finalMenus = await this.prismaService.$tx(async (prisma) => {
×
NEW
296
      // Delete redundant
×
NEW
297
      if (toDelete.length > 0) {
×
NEW
298
        await prisma.baseNode.deleteMany({
×
NEW
299
          where: { id: { in: toDelete.map((m) => m.id) } },
×
NEW
300
        });
×
NEW
301
      }
×
302

NEW
303
      // Prepare for create and update
×
NEW
304
      let nextOrder = 0;
×
NEW
305
      if (toCreate.length > 0 || orphans.length > 0) {
×
NEW
306
        const maxOrderAgg = await prisma.baseNode.aggregate({
×
NEW
307
          where: { baseId },
×
NEW
308
          _max: { order: true },
×
NEW
309
        });
×
NEW
310
        nextOrder = (maxOrderAgg._max.order ?? 0) + 1;
×
NEW
311
      }
×
312

NEW
313
      // Create missing
×
NEW
314
      if (toCreate.length > 0) {
×
NEW
315
        await prisma.baseNode.createMany({
×
NEW
316
          data: toCreate.map((r) => ({
×
NEW
317
            id: generateBaseNodeId(),
×
NEW
318
            baseId,
×
NEW
319
            resourceType: r.type,
×
NEW
320
            resourceId: r.id,
×
NEW
321
            order: nextOrder++,
×
NEW
322
            parentId: null,
×
NEW
323
            createdBy: this.userId,
×
NEW
324
          })),
×
NEW
325
        });
×
NEW
326
      }
×
327

NEW
328
      // Reset orphans to root level with new order
×
NEW
329
      if (orphans.length > 0) {
×
NEW
330
        await this.batchUpdateBaseNodes(
×
NEW
331
          orphans.map((orphan, index) => ({
×
NEW
332
            id: orphan.id,
×
NEW
333
            values: { parentId: null, order: nextOrder + index },
×
NEW
334
          }))
×
335
        );
NEW
336
      }
×
NEW
337
      return prisma.baseNode.findMany({
×
NEW
338
        where: { baseId },
×
NEW
339
        select: this.getSelect(),
×
NEW
340
        orderBy: { order: 'asc' },
×
NEW
341
      });
×
NEW
342
    });
×
343

NEW
344
    return await Promise.all(
×
NEW
345
      finalMenus.map(async (entry) => {
×
NEW
346
        const key = `${entry.resourceType}_${entry.resourceId}`;
×
NEW
347
        const resource = resourceMap[key];
×
NEW
348
        return await this.entry2vo(entry, omit(resource, 'id'));
×
NEW
349
      })
×
350
    );
NEW
351
  }
×
352

353
  async getNodeListWithCache(baseId: string): Promise<IBaseNodeVo[]> {
117✔
354
    return this.performanceCacheService.wrap(
16✔
355
      generateBaseNodeListCacheKey(baseId),
16✔
356
      () => this.prepareNodeList(baseId),
16✔
357
      {
16✔
358
        ttl: 60 * 60, // 1 hour
16✔
359
        statsType: 'base-node-list',
16✔
360
      }
16✔
361
    );
362
  }
16✔
363

364
  async getList(baseId: string): Promise<IBaseNodeVo[]> {
117✔
365
    return this.getNodeListWithCache(baseId);
3✔
366
  }
3✔
367

368
  async getTree(baseId: string): Promise<IBaseNodeTreeVo> {
117✔
369
    const nodes = await this.getNodeListWithCache(baseId);
13✔
370

371
    return {
13✔
372
      nodes,
13✔
373
      maxFolderDepth,
13✔
374
    };
13✔
375
  }
13✔
376

377
  async getNode(baseId: string, nodeId: string) {
117✔
378
    const node = await this.prismaService.baseNode
8✔
379
      .findFirstOrThrow({
8✔
380
        where: { baseId, id: nodeId },
8✔
381
        select: this.getSelect(),
8✔
382
      })
8✔
383
      .catch(() => {
8✔
NEW
384
        throw new CustomHttpException(`Base node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
385
          localization: {
×
NEW
386
            i18nKey: 'httpErrors.baseNode.notFound',
×
NEW
387
          },
×
NEW
388
        });
×
NEW
389
      });
×
390
    return {
8✔
391
      ...node,
8✔
392
      resourceType: node.resourceType as BaseNodeResourceType,
8✔
393
    };
8✔
394
  }
8✔
395

396
  async getNodeVo(baseId: string, nodeId: string): Promise<IBaseNodeVo> {
117✔
397
    const node = await this.getNode(baseId, nodeId);
8✔
398
    return this.entry2vo(node);
8✔
399
  }
8✔
400

401
  async create(baseId: string, ro: ICreateBaseNodeRo): Promise<IBaseNodeVo> {
117✔
402
    const { resourceType, parentId } = ro;
110✔
403

404
    const { entry, resource } = await this.prismaService.$tx(async (prisma) => {
110✔
405
      const resource = await this.createResource(baseId, ro);
110✔
406
      const resourceId = resource.id;
107✔
407

408
      const maxOrder = await this.getMaxOrder(baseId);
107✔
409
      const entry = await prisma.baseNode.create({
107✔
410
        data: {
107✔
411
          id: generateBaseNodeId(),
107✔
412
          baseId,
107✔
413
          resourceType,
107✔
414
          resourceId,
107✔
415
          order: maxOrder + 1,
107✔
416
          parentId,
107✔
417
          createdBy: this.userId,
107✔
418
        },
107✔
419
        select: this.getSelect(),
107✔
420
      });
107✔
421

422
      return {
107✔
423
        entry,
107✔
424
        resource,
107✔
425
      };
107✔
426
    });
107✔
427

428
    const vo = await this.entry2vo(entry, omit(resource, 'id'));
107✔
429
    this.presenceHandler(baseId, (presence) => {
107✔
430
      presence.submit({
107✔
431
        event: 'create',
107✔
432
        data: { ...vo },
107✔
433
      });
107✔
434
    });
107✔
435
    return vo;
107✔
436
  }
107✔
437

438
  protected async createResource(
117✔
439
    baseId: string,
110✔
440
    createRo: ICreateBaseNodeRo
110✔
441
  ): Promise<IBaseNodeResourceMetaWithId> {
110✔
442
    const { resourceType, parentId, ...ro } = createRo;
110✔
443
    const parentNode = parentId ? await this.getParentNodeOrThrow(parentId) : null;
110✔
444
    if (parentNode && parentNode.resourceType !== BaseNodeResourceType.Folder) {
110✔
445
      throw new CustomHttpException('Parent must be a folder', HttpErrorCode.VALIDATION_ERROR, {
1✔
446
        localization: {
1✔
447
          i18nKey: 'httpErrors.baseNode.parentMustBeFolder',
1✔
448
        },
1✔
449
      });
1✔
450
    }
1✔
451

452
    if (parentNode && resourceType === BaseNodeResourceType.Folder) {
110✔
453
      await this.assertFolderDepth(baseId, parentNode.id);
21✔
454
    }
20✔
455

456
    switch (resourceType) {
107✔
457
      case BaseNodeResourceType.Folder: {
110✔
458
        const folder = await this.baseNodeFolderService.createFolder(
70✔
459
          baseId,
70✔
460
          ro as ICreateFolderNodeRo
70✔
461
        );
462
        return { id: folder.id, name: folder.name };
70✔
463
      }
70✔
464
      case BaseNodeResourceType.Table: {
110✔
465
        const preparedRo = prepareCreateTableRo(ro as ICreateTableRo);
27✔
466
        const table = await this.tableOpenApiService.createTable(baseId, preparedRo);
27✔
467

468
        return {
27✔
469
          id: table.id,
27✔
470
          name: table.name,
27✔
471
          icon: table.icon,
27✔
472
          defaultViewId: table.defaultViewId,
27✔
473
        };
27✔
474
      }
27✔
475
      case BaseNodeResourceType.Dashboard: {
110✔
476
        const dashboard = await this.dashboardService.createDashboard(
10✔
477
          baseId,
10✔
478
          ro as ICreateDashboardRo
10✔
479
        );
480
        return { id: dashboard.id, name: dashboard.name };
10✔
481
      }
10✔
482
      default:
110!
NEW
483
        throw new CustomHttpException(
×
NEW
484
          `Invalid resource type ${resourceType}`,
×
NEW
485
          HttpErrorCode.VALIDATION_ERROR,
×
NEW
486
          {
×
NEW
487
            localization: {
×
NEW
488
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
×
NEW
489
            },
×
NEW
490
          }
×
491
        );
492
    }
110✔
493
  }
110✔
494

495
  async duplicate(baseId: string, nodeId: string, ro: IDuplicateBaseNodeRo) {
117✔
496
    const anchor = await this.prismaService.baseNode
5✔
497
      .findFirstOrThrow({
5✔
498
        where: { baseId, id: nodeId },
5✔
499
      })
5✔
500
      .catch(() => {
5✔
NEW
501
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
502
          localization: {
×
NEW
503
            i18nKey: 'httpErrors.baseNode.notFound',
×
NEW
504
          },
×
NEW
505
        });
×
NEW
506
      });
×
507
    const { resourceType, resourceId } = anchor;
5✔
508

509
    if (resourceType === BaseNodeResourceType.Folder) {
5✔
510
      throw new CustomHttpException('Cannot duplicate folder', HttpErrorCode.VALIDATION_ERROR, {
1✔
511
        localization: {
1✔
512
          i18nKey: 'httpErrors.baseNode.cannotDuplicateFolder',
1✔
513
        },
1✔
514
      });
1✔
515
    }
1✔
516

517
    const { entry, resource } = await this.prismaService.$tx(async (prisma) => {
4✔
518
      const resource = await this.duplicateResource(
4✔
519
        baseId,
4✔
520
        resourceType as BaseNodeResourceType,
4✔
521
        resourceId,
4✔
522
        ro
4✔
523
      );
524

525
      const maxOrder = await this.getMaxOrder(baseId, anchor.parentId);
4✔
526
      const newNodeId = generateBaseNodeId();
4✔
527
      const entry = await prisma.baseNode.create({
4✔
528
        data: {
4✔
529
          id: newNodeId,
4✔
530
          baseId,
4✔
531
          resourceType,
4✔
532
          resourceId: resource.id,
4✔
533
          order: maxOrder + 1,
4✔
534
          parentId: anchor.parentId,
4✔
535
          createdBy: this.userId,
4✔
536
        },
4✔
537
        select: this.getSelect(),
4✔
538
      });
4✔
539

540
      await updateOrder({
4✔
541
        query: baseId,
4✔
542
        position: 'after',
4✔
543
        item: entry,
4✔
544
        anchorItem: anchor,
4✔
545
        getNextItem: async (whereOrder, align) => {
4✔
546
          return prisma.baseNode.findFirst({
4✔
547
            where: {
4✔
548
              baseId,
4✔
549
              parentId: anchor.parentId,
4✔
550
              order: whereOrder,
4✔
551
              id: { not: newNodeId },
4✔
552
            },
4✔
553
            select: { order: true, id: true },
4✔
554
            orderBy: { order: align },
4✔
555
          });
4✔
556
        },
4✔
557
        update: async (_, id, data) => {
4✔
558
          await prisma.baseNode.update({
4✔
559
            where: { id },
4✔
560
            data: { parentId: anchor.parentId, order: data.newOrder },
4✔
561
          });
4✔
562
        },
4✔
563
        shuffle: async () => {
4✔
NEW
564
          await this.shuffleOrders(baseId, anchor.parentId);
×
NEW
565
        },
×
566
      });
4✔
567

568
      return {
4✔
569
        entry,
4✔
570
        resource,
4✔
571
      };
4✔
572
    });
4✔
573

574
    const vo = await this.entry2vo(entry, omit(resource, 'id'));
4✔
575
    this.presenceHandler(baseId, (presence) => {
4✔
576
      presence.submit({
4✔
577
        event: 'create',
4✔
578
        data: { ...vo },
4✔
579
      });
4✔
580
    });
4✔
581
    return vo;
4✔
582
  }
4✔
583

584
  protected async duplicateResource(
117✔
585
    baseId: string,
4✔
586
    type: BaseNodeResourceType,
4✔
587
    id: string,
4✔
588
    duplicateRo: IDuplicateBaseNodeRo
4✔
589
  ): Promise<IBaseNodeResourceMetaWithId> {
4✔
590
    switch (type) {
4✔
591
      case BaseNodeResourceType.Table: {
4✔
592
        const table = await this.tableDuplicateService.duplicateTable(
2✔
593
          baseId,
2✔
594
          id,
2✔
595
          duplicateRo as IDuplicateTableRo
2✔
596
        );
597

598
        return {
2✔
599
          id: table.id,
2✔
600
          name: table.name,
2✔
601
          icon: table.icon ?? undefined,
2✔
602
          defaultViewId: table.defaultViewId,
2✔
603
        };
2✔
604
      }
2✔
605
      case BaseNodeResourceType.Dashboard: {
4✔
606
        const dashboard = await this.dashboardService.duplicateDashboard(
2✔
607
          baseId,
2✔
608
          id,
2✔
609
          duplicateRo as IDuplicateDashboardRo
2✔
610
        );
611
        return { id: dashboard.id, name: dashboard.name };
2✔
612
      }
2✔
613
      default:
4!
NEW
614
        throw new CustomHttpException(
×
NEW
615
          `Invalid resource type ${type}`,
×
NEW
616
          HttpErrorCode.VALIDATION_ERROR,
×
NEW
617
          {
×
NEW
618
            localization: {
×
NEW
619
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
×
NEW
620
            },
×
NEW
621
          }
×
622
        );
623
    }
4✔
624
  }
4✔
625

626
  async update(baseId: string, nodeId: string, ro: IUpdateBaseNodeRo) {
117✔
627
    const node = await this.prismaService.baseNode
7✔
628
      .findFirstOrThrow({
7✔
629
        where: { baseId, id: nodeId },
7✔
630
        select: this.getSelect(),
7✔
631
      })
7✔
632
      .catch(() => {
7✔
NEW
633
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
634
          localization: {
×
NEW
635
            i18nKey: 'httpErrors.baseNode.notFound',
×
NEW
636
          },
×
NEW
637
        });
×
NEW
638
      });
×
639

640
    await this.prismaService.$tx(async () => {
7✔
641
      await this.updateResource(
7✔
642
        baseId,
7✔
643
        node.resourceType as BaseNodeResourceType,
7✔
644
        node.resourceId,
7✔
645
        ro
7✔
646
      );
647
    });
7✔
648

649
    const vo = await this.entry2vo(node);
7✔
650
    this.presenceHandler(baseId, (presence) => {
7✔
651
      presence.submit({
7✔
652
        event: 'update',
7✔
653
        data: { ...vo },
7✔
654
      });
7✔
655
    });
7✔
656
    return vo;
7✔
657
  }
7✔
658

659
  protected async updateResource(
117✔
660
    baseId: string,
7✔
661
    type: BaseNodeResourceType,
7✔
662
    id: string,
7✔
663
    updateRo: IUpdateBaseNodeRo
7✔
664
  ): Promise<void> {
7✔
665
    const { name, icon } = updateRo;
7✔
666
    switch (type) {
7✔
667
      case BaseNodeResourceType.Folder:
7!
NEW
668
        if (name) {
×
NEW
669
          await this.baseNodeFolderService.renameFolder(baseId, id, { name });
×
NEW
670
        }
×
NEW
671
        break;
×
672
      case BaseNodeResourceType.Table:
7✔
673
        if (name) {
7✔
674
          await this.tableOpenApiService.updateName(baseId, id, name);
5✔
675
        }
5✔
676
        if (icon) {
7✔
677
          await this.tableOpenApiService.updateIcon(baseId, id, icon);
3✔
678
        }
3✔
679
        break;
7✔
680
      case BaseNodeResourceType.Dashboard:
7!
NEW
681
        if (name) {
×
NEW
682
          await this.dashboardService.renameDashboard(baseId, id, name);
×
NEW
683
        }
×
NEW
684
        break;
×
685
      default:
7!
NEW
686
        throw new CustomHttpException(
×
NEW
687
          `Invalid resource type ${type}`,
×
NEW
688
          HttpErrorCode.VALIDATION_ERROR,
×
NEW
689
          {
×
NEW
690
            localization: {
×
NEW
691
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
×
NEW
692
            },
×
NEW
693
          }
×
694
        );
695
    }
7✔
696
  }
7✔
697

698
  async delete(baseId: string, nodeId: string, permanent?: boolean) {
117✔
699
    const node = await this.prismaService.baseNode
99✔
700
      .findFirstOrThrow({
99✔
701
        where: { baseId, id: nodeId },
99✔
702
      })
99✔
703
      .catch(() => {
99✔
NEW
704
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
705
          localization: {
×
NEW
706
            i18nKey: 'httpErrors.baseNode.notFound',
×
NEW
707
          },
×
NEW
708
        });
×
NEW
709
      });
×
710
    if (node.resourceType === BaseNodeResourceType.Folder) {
99✔
711
      const children = await this.prismaService.baseNode.findMany({
66✔
712
        where: { baseId, parentId: nodeId },
66✔
713
      });
66✔
714
      if (children.length > 0) {
66✔
715
        throw new CustomHttpException(
2✔
716
          'Cannot delete folder because it is not empty',
2✔
717
          HttpErrorCode.VALIDATION_ERROR,
2✔
718
          {
2✔
719
            localization: {
2✔
720
              i18nKey: 'httpErrors.baseNode.cannotDeleteEmptyFolder',
2✔
721
            },
2✔
722
          }
2✔
723
        );
724
      }
2✔
725
    }
66✔
726

727
    await this.prismaService.$tx(async (prisma) => {
97✔
728
      await this.deleteResource(
97✔
729
        baseId,
97✔
730
        node.resourceType as BaseNodeResourceType,
97✔
731
        node.resourceId,
97✔
732
        permanent
97✔
733
      );
734
      await prisma.baseNode.delete({
97✔
735
        where: { id: nodeId },
97✔
736
      });
97✔
737
    });
97✔
738

739
    this.presenceHandler(baseId, (presence) => {
97✔
740
      presence.submit({
97✔
741
        event: 'delete',
97✔
742
        data: { id: nodeId },
97✔
743
      });
97✔
744
    });
97✔
745
  }
97✔
746

747
  protected async deleteResource(
117✔
748
    baseId: string,
97✔
749
    type: BaseNodeResourceType,
97✔
750
    id: string,
97✔
751
    permanent?: boolean
97✔
752
  ) {
97✔
753
    switch (type) {
97✔
754
      case BaseNodeResourceType.Folder:
97✔
755
        await this.baseNodeFolderService.deleteFolder(baseId, id);
64✔
756
        break;
64✔
757
      case BaseNodeResourceType.Table:
97✔
758
        if (permanent) {
25!
NEW
759
          await this.tableOpenApiService.permanentDeleteTables(baseId, [id]);
×
760
        } else {
25✔
761
          await this.tableOpenApiService.deleteTable(baseId, id);
25✔
762
        }
25✔
763
        break;
25✔
764
      case BaseNodeResourceType.Dashboard:
97✔
765
        await this.dashboardService.deleteDashboard(baseId, id);
8✔
766
        break;
8✔
767
      default:
97!
NEW
768
        throw new CustomHttpException(
×
NEW
769
          `Invalid resource type ${type}`,
×
NEW
770
          HttpErrorCode.VALIDATION_ERROR,
×
NEW
771
          {
×
NEW
772
            localization: {
×
NEW
773
              i18nKey: 'httpErrors.baseNode.invalidResourceType',
×
NEW
774
            },
×
NEW
775
          }
×
776
        );
777
    }
97✔
778
  }
97✔
779

780
  async move(baseId: string, nodeId: string, ro: IMoveBaseNodeRo): Promise<IBaseNodeVo> {
117✔
781
    const { parentId, anchorId, position } = ro;
20✔
782

783
    const node = await this.prismaService.baseNode
20✔
784
      .findFirstOrThrow({
20✔
785
        where: { baseId, id: nodeId },
20✔
786
      })
20✔
787
      .catch(() => {
20✔
NEW
788
        throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
789
          localization: {
×
NEW
790
            i18nKey: 'httpErrors.baseNode.notFound',
×
NEW
791
          },
×
NEW
792
        });
×
NEW
793
      });
×
794

795
    if (isString(parentId) && isString(anchorId)) {
20!
NEW
796
      throw new CustomHttpException(
×
NEW
797
        'Only one of parentId or anchorId must be provided',
×
NEW
798
        HttpErrorCode.VALIDATION_ERROR,
×
NEW
799
        {
×
NEW
800
          localization: {
×
NEW
801
            i18nKey: 'httpErrors.baseNode.onlyOneOfParentIdOrAnchorIdRequired',
×
NEW
802
          },
×
NEW
803
        }
×
804
      );
NEW
805
    }
×
806

807
    if (parentId === nodeId) {
20!
808
      throw new CustomHttpException('Cannot move node to itself', HttpErrorCode.VALIDATION_ERROR, {
1✔
809
        localization: {
1✔
810
          i18nKey: 'httpErrors.baseNode.cannotMoveToItself',
1✔
811
        },
1✔
812
      });
1✔
813
    }
1✔
814

815
    if (anchorId === nodeId) {
20!
NEW
816
      throw new CustomHttpException(
×
NEW
817
        'Cannot move node to its own child (circular reference)',
×
NEW
818
        HttpErrorCode.VALIDATION_ERROR,
×
NEW
819
        {
×
NEW
820
          localization: {
×
NEW
821
            i18nKey: 'httpErrors.baseNode.cannotMoveToCircularReference',
×
NEW
822
          },
×
NEW
823
        }
×
824
      );
NEW
825
    }
✔
826

827
    let newNode: IBaseNodeEntry;
19✔
828
    if (anchorId) {
20!
829
      newNode = await this.moveNodeTo(baseId, node.id, { anchorId, position });
7✔
830
    } else if (parentId === null) {
20!
831
      newNode = await this.moveNodeToRoot(baseId, node.id);
2✔
832
    } else if (parentId) {
12✔
833
      newNode = await this.moveNodeToFolder(baseId, node.id, parentId);
10✔
834
    } else {
10!
NEW
835
      throw new CustomHttpException(
×
NEW
836
        'At least one of parentId or anchorId must be provided',
×
NEW
837
        HttpErrorCode.VALIDATION_ERROR,
×
NEW
838
        {
×
NEW
839
          localization: {
×
NEW
840
            i18nKey: 'httpErrors.baseNode.anchorIdOrParentIdRequired',
×
NEW
841
          },
×
NEW
842
        }
×
843
      );
NEW
844
    }
✔
845

846
    const vo = await this.entry2vo(newNode);
14✔
847
    this.presenceHandler(baseId, (presence) => {
14✔
848
      presence.submit({
14✔
849
        event: 'update',
14✔
850
        data: { ...vo },
14✔
851
      });
14✔
852
    });
14✔
853

854
    return vo;
14✔
855
  }
14✔
856

857
  private async moveNodeToRoot(baseId: string, nodeId: string) {
117✔
858
    return this.prismaService.$tx(async (prisma) => {
2✔
859
      const maxOrder = await this.getMaxOrder(baseId);
2✔
860
      return prisma.baseNode.update({
2✔
861
        where: { id: nodeId },
2✔
862
        select: this.getSelect(),
2✔
863
        data: {
2✔
864
          parentId: null,
2✔
865
          order: maxOrder + 1,
2✔
866
          lastModifiedBy: this.userId,
2✔
867
        },
2✔
868
      });
2✔
869
    });
2✔
870
  }
2✔
871

872
  private async moveNodeToFolder(baseId: string, nodeId: string, parentId: string) {
117✔
873
    return this.prismaService.$tx(async (prisma) => {
10✔
874
      const node = await prisma.baseNode
10✔
875
        .findFirstOrThrow({
10✔
876
          where: { baseId, id: nodeId },
10✔
877
        })
10✔
878
        .catch(() => {
10✔
NEW
879
          throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
880
            localization: {
×
NEW
881
              i18nKey: 'httpErrors.baseNode.notFound',
×
NEW
882
            },
×
NEW
883
          });
×
NEW
884
        });
×
885

886
      const parentNode = await prisma.baseNode
10✔
887
        .findFirstOrThrow({
10✔
888
          where: { baseId, id: parentId },
10✔
889
        })
10✔
890
        .catch(() => {
10✔
NEW
891
          throw new CustomHttpException(`Parent ${parentId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
892
            localization: {
×
NEW
893
              i18nKey: 'httpErrors.baseNode.parentNotFound',
×
NEW
894
            },
×
NEW
895
          });
×
NEW
896
        });
×
897

898
      if (parentNode.resourceType !== BaseNodeResourceType.Folder) {
10!
899
        throw new CustomHttpException(
1✔
900
          `Parent ${parentId} is not a folder`,
1✔
901
          HttpErrorCode.VALIDATION_ERROR,
1✔
902
          {
1✔
903
            localization: {
1✔
904
              i18nKey: 'httpErrors.baseNode.parentIsNotFolder',
1✔
905
            },
1✔
906
          }
1✔
907
        );
908
      }
1✔
909

910
      if (node.resourceType === BaseNodeResourceType.Folder && parentId) {
10!
911
        await this.assertFolderDepth(baseId, parentId);
3✔
912
      }
1✔
913

914
      // Check for circular reference
7✔
915
      const isCircular = await this.isCircularReference(baseId, nodeId, parentId);
7✔
916
      if (isCircular) {
10!
NEW
917
        throw new CustomHttpException(
×
NEW
918
          'Cannot move node to its own child (circular reference)',
×
NEW
919
          HttpErrorCode.VALIDATION_ERROR,
×
NEW
920
          {
×
NEW
921
            localization: {
×
NEW
922
              i18nKey: 'httpErrors.baseNode.circularReference',
×
NEW
923
            },
×
NEW
924
          }
×
925
        );
NEW
926
      }
✔
927

928
      const maxOrder = await this.getMaxOrder(baseId);
7✔
929
      return prisma.baseNode.update({
7✔
930
        where: { id: nodeId },
7✔
931
        select: this.getSelect(),
7✔
932
        data: {
7✔
933
          parentId,
7✔
934
          order: maxOrder + 1,
7✔
935
          lastModifiedBy: this.userId,
7✔
936
        },
7✔
937
      });
7✔
938
    });
7✔
939
  }
10✔
940

941
  private async moveNodeTo(
117✔
942
    baseId: string,
7✔
943
    nodeId: string,
7✔
944
    ro: Pick<IMoveBaseNodeRo, 'anchorId' | 'position'>
7✔
945
  ): Promise<IBaseNodeEntry> {
7✔
946
    const { anchorId, position } = ro;
7✔
947
    return this.prismaService.$tx(async (prisma) => {
7✔
948
      const node = await prisma.baseNode
7✔
949
        .findFirstOrThrow({
7✔
950
          where: { baseId, id: nodeId },
7✔
951
        })
7✔
952
        .catch(() => {
7✔
NEW
953
          throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
×
NEW
954
            localization: {
×
NEW
955
              i18nKey: 'httpErrors.baseNode.notFound',
×
NEW
956
            },
×
NEW
957
          });
×
NEW
958
        });
×
959

960
      const anchor = await prisma.baseNode
7✔
961
        .findFirstOrThrow({
7✔
962
          where: { baseId, id: anchorId },
7✔
963
        })
7✔
964
        .catch(() => {
7✔
965
          throw new CustomHttpException(`Anchor ${anchorId} not found`, HttpErrorCode.NOT_FOUND, {
1✔
966
            localization: {
1✔
967
              i18nKey: 'httpErrors.baseNode.anchorNotFound',
1✔
968
            },
1✔
969
          });
1✔
970
        });
1✔
971

972
      if (node.resourceType === BaseNodeResourceType.Folder && anchor.parentId) {
7✔
973
        await this.assertFolderDepth(baseId, anchor.parentId);
4✔
974
      }
3✔
975

976
      await updateOrder({
5✔
977
        query: baseId,
5✔
978
        position: position ?? 'after',
7!
979
        item: node,
7✔
980
        anchorItem: anchor,
7✔
981
        getNextItem: async (whereOrder, align) => {
7✔
982
          return prisma.baseNode.findFirst({
5✔
983
            where: {
5✔
984
              baseId,
5✔
985
              parentId: anchor.parentId,
5✔
986
              order: whereOrder,
5✔
987
            },
5✔
988
            select: { order: true, id: true },
5✔
989
            orderBy: { order: align },
5✔
990
          });
5✔
991
        },
5✔
992
        update: async (_, id, data) => {
7✔
993
          await prisma.baseNode.update({
5✔
994
            where: { id },
5✔
995
            data: { parentId: anchor.parentId, order: data.newOrder },
5✔
996
          });
5✔
997
        },
5✔
998
        shuffle: async () => {
7✔
NEW
999
          await this.shuffleOrders(baseId, anchor.parentId);
×
NEW
1000
        },
×
1001
      });
7✔
1002

1003
      return prisma.baseNode.findFirstOrThrow({
5✔
1004
        where: { baseId, id: nodeId },
5✔
1005
        select: this.getSelect(),
5✔
1006
      });
5✔
1007
    });
5✔
1008
  }
7✔
1009

1010
  @OnEvent(Events.BASE_FOLDER_CREATE)
117✔
1011
  @OnEvent(Events.TABLE_CREATE)
2,440✔
1012
  @OnEvent(Events.DASHBOARD_CREATE)
2,440✔
1013
  @OnEvent(Events.WORKFLOW_CREATE)
2,440✔
1014
  @OnEvent(Events.APP_CREATE)
2,440✔
1015
  async onResourceCreate(event: IResourceCreateEvent) {
2,440✔
1016
    const { baseId, resourceType, resourceId, userId } = this.prepareResourceCreate(event);
2,440✔
1017

1018
    if (!baseId || !resourceType || !resourceId) {
2,440!
NEW
1019
      this.logger.error('Invalid resource create event', event);
×
NEW
1020
      return;
×
NEW
1021
    }
✔
1022

1023
    const createNode = async (prisma: PrismaService) => {
2,424✔
1024
      const findNode = await prisma.baseNode.findFirst({
2,424✔
1025
        where: { baseId, resourceType, resourceId },
2,424✔
1026
      });
2,424✔
1027
      if (findNode) {
2,424✔
1028
        return;
49✔
1029
      }
49✔
1030
      const maxOrder = await this.getMaxOrder(baseId);
2,375✔
1031
      await prisma.baseNode.create({
2,375✔
1032
        data: {
2,375✔
1033
          id: generateBaseNodeId(),
2,375✔
1034
          baseId,
2,375✔
1035
          resourceType,
2,375✔
1036
          resourceId,
2,375✔
1037
          parentId: null,
2,375✔
1038
          order: maxOrder + 1,
2,375✔
1039
          createdBy: userId || ANONYMOUS_USER_ID,
2,399!
1040
        },
2,424✔
1041
      });
2,424✔
1042
    };
2,375✔
1043
    await createNode(this.prismaService);
2,424✔
1044

1045
    this.presenceHandler(baseId, (presence) => {
2,424✔
1046
      presence.submit({
2,424✔
1047
        event: 'flush',
2,424✔
1048
      });
2,424✔
1049
    });
2,424✔
1050
  }
2,424✔
1051

1052
  private prepareResourceCreate(event: IResourceCreateEvent) {
117✔
1053
    let baseId: string;
2,440✔
1054
    let resourceType: BaseNodeResourceType | undefined;
2,440✔
1055
    let resourceId: string | undefined;
2,440✔
1056
    let name: string | undefined;
2,440✔
1057
    let icon: string | undefined;
2,440✔
1058
    switch (event.name) {
2,440✔
1059
      case Events.BASE_FOLDER_CREATE:
2,440!
NEW
1060
        baseId = event.payload.baseId;
×
NEW
1061
        resourceType = BaseNodeResourceType.Folder;
×
NEW
1062
        resourceId = event.payload.folder.id;
×
NEW
1063
        name = event.payload.folder.name;
×
NEW
1064
        break;
×
1065
      case Events.TABLE_CREATE:
2,440✔
1066
        baseId = event.payload.baseId;
2,402✔
1067
        resourceType = BaseNodeResourceType.Table;
2,402✔
1068
        // get the table id from the table op
2,402✔
1069
        resourceId = (event.payload.table as unknown as { id: string }).id;
2,402✔
1070
        name = event.payload.table.name;
2,402✔
1071
        icon = event.payload.table.icon;
2,402✔
1072
        break;
2,402✔
1073
      case Events.WORKFLOW_CREATE:
2,440!
NEW
1074
        baseId = event.payload.baseId;
×
NEW
1075
        resourceType = BaseNodeResourceType.Workflow;
×
NEW
1076
        resourceId = event.payload.workflow.id;
×
NEW
1077
        name = event.payload.workflow.name;
×
NEW
1078
        break;
×
1079
      case Events.DASHBOARD_CREATE:
2,440!
1080
        baseId = event.payload.baseId;
23✔
1081
        resourceType = BaseNodeResourceType.Dashboard;
23✔
1082
        resourceId = event.payload.dashboard.id;
23✔
1083
        name = event.payload.dashboard.name;
23✔
1084
        break;
23✔
1085
      case Events.APP_CREATE:
2,440!
NEW
1086
        baseId = event.payload.baseId;
×
NEW
1087
        resourceType = BaseNodeResourceType.App;
×
NEW
1088
        resourceId = event.payload.app.id;
×
NEW
1089
        name = event.payload.app.name;
×
NEW
1090
        break;
×
1091
    }
2,440✔
1092
    return {
2,439✔
1093
      baseId,
2,439✔
1094
      resourceType,
2,439✔
1095
      resourceId,
2,439✔
1096
      name,
2,439✔
1097
      icon,
2,439✔
1098
      userId: event.context.user?.id,
2,439✔
1099
    };
2,440✔
1100
  }
2,440✔
1101

1102
  @OnEvent(Events.BASE_FOLDER_UPDATE)
117✔
1103
  @OnEvent(Events.TABLE_UPDATE)
20✔
1104
  @OnEvent(Events.DASHBOARD_UPDATE)
20✔
1105
  @OnEvent(Events.WORKFLOW_UPDATE)
20✔
1106
  @OnEvent(Events.APP_UPDATE)
20✔
1107
  async onResourceUpdate(event: IResourceUpdateEvent) {
20✔
1108
    const { baseId, resourceType, resourceId } = this.prepareResourceUpdate(event);
20✔
1109
    if (baseId && resourceType && resourceId) {
20✔
1110
      this.presenceHandler(baseId, (presence) => {
16✔
1111
        presence.submit({
16✔
1112
          event: 'flush',
16✔
1113
        });
16✔
1114
      });
16✔
1115
    }
16✔
1116
  }
20✔
1117

1118
  private prepareResourceUpdate(event: IResourceUpdateEvent) {
117✔
1119
    let baseId: string;
20✔
1120
    let resourceType: BaseNodeResourceType | undefined;
20✔
1121
    let resourceId: string | undefined;
20✔
1122
    let name: string | undefined;
20✔
1123
    let icon: string | undefined;
20✔
1124
    switch (event.name) {
20✔
1125
      case Events.TABLE_UPDATE:
20✔
1126
        baseId = event.payload.baseId;
15✔
1127
        resourceType = BaseNodeResourceType.Table;
15✔
1128
        resourceId = event.payload.table.id;
15✔
1129
        name = event.payload.table?.name?.newValue as string;
15✔
1130
        icon = event.payload.table?.icon?.newValue as string;
15✔
1131
        break;
15✔
1132
      case Events.WORKFLOW_UPDATE:
20!
NEW
1133
        baseId = event.payload.baseId;
×
NEW
1134
        resourceType = BaseNodeResourceType.Workflow;
×
NEW
1135
        resourceId = event.payload.workflow.id;
×
NEW
1136
        name = event.payload.workflow.name;
×
NEW
1137
        break;
×
1138
      case Events.DASHBOARD_UPDATE:
20!
1139
        baseId = event.payload.baseId;
1✔
1140
        resourceType = BaseNodeResourceType.Dashboard;
1✔
1141
        resourceId = event.payload.dashboard.id;
1✔
1142
        name = event.payload.dashboard.name;
1✔
1143
        break;
1✔
1144
      case Events.APP_UPDATE:
20!
NEW
1145
        baseId = event.payload.baseId;
×
NEW
1146
        resourceType = BaseNodeResourceType.App;
×
NEW
1147
        resourceId = event.payload.app.id;
×
NEW
1148
        name = event.payload.app.name;
×
NEW
1149
        break;
×
1150
      case Events.BASE_FOLDER_UPDATE:
20!
NEW
1151
        baseId = event.payload.baseId;
×
NEW
1152
        resourceType = BaseNodeResourceType.Folder;
×
NEW
1153
        resourceId = event.payload.folder.id;
×
NEW
1154
        name = event.payload.folder.name;
×
NEW
1155
        break;
×
1156
    }
20✔
1157
    return {
20✔
1158
      baseId,
20✔
1159
      resourceType,
20✔
1160
      resourceId,
20✔
1161
      name,
20✔
1162
      icon,
20✔
1163
    };
20✔
1164
  }
20✔
1165

1166
  @OnEvent(Events.BASE_DELETE)
117✔
1167
  @OnEvent(Events.BASE_FOLDER_DELETE)
2,492✔
1168
  @OnEvent(Events.TABLE_DELETE)
2,492✔
1169
  @OnEvent(Events.DASHBOARD_DELETE)
2,492✔
1170
  @OnEvent(Events.WORKFLOW_DELETE)
2,492✔
1171
  @OnEvent(Events.APP_DELETE)
2,492✔
1172
  async onResourceDelete(event: IResourceDeleteEvent) {
2,492✔
1173
    const { baseId, resourceType, resourceId } = this.prepareResourceDelete(event);
2,492✔
1174
    if (!baseId) {
2,492!
1175
      return;
15✔
1176
    }
15✔
1177
    if (event.name === Events.BASE_DELETE) {
2,477✔
1178
      await this.prismaService.baseNode.deleteMany({
162✔
1179
        where: { baseId },
162✔
1180
      });
162✔
1181
      return;
162✔
1182
    }
162✔
1183
    if (!resourceType || !resourceId) {
2,492✔
1184
      this.logger.error('Invalid resource delete event', event);
16✔
1185
      return;
16✔
1186
    }
16✔
1187

1188
    const deleteNode = async (prisma: Prisma.TransactionClient) => {
2,299✔
1189
      const toDeleteNode = await prisma.baseNode.findFirst({
2,299✔
1190
        where: { baseId, resourceType, resourceId },
2,299✔
1191
      });
2,299✔
1192
      if (!toDeleteNode) {
2,299!
1193
        return;
28✔
1194
      }
28✔
1195
      await prisma.baseNode.deleteMany({
2,271✔
1196
        where: { id: toDeleteNode.id },
2,271✔
1197
      });
2,271✔
1198
      const maxOrder = await this.getMaxOrder(baseId);
2,266✔
1199
      const orphans = await prisma.baseNode.findMany({
2,199✔
1200
        where: { baseId, parentId: toDeleteNode.parentId },
2,199✔
1201
        select: { id: true, order: true },
2,199✔
1202
      });
2,199✔
1203
      if (orphans.length > 0) {
2,242✔
1204
        await this.batchUpdateBaseNodes(
2,059✔
1205
          orphans.map((orphan) => ({
2,059✔
1206
            id: orphan.id,
12,041✔
1207
            values: {
12,041✔
1208
              parentId: null,
12,041✔
1209
              order: maxOrder + orphan.order + 1,
12,041✔
1210
            },
12,041✔
1211
          }))
12,041✔
1212
        );
1213
      }
2,058✔
1214
    };
2,299✔
1215
    await deleteNode(this.prismaService);
2,299✔
1216

1217
    this.presenceHandler(baseId, (presence) => {
2,224✔
1218
      presence.submit({
2,224✔
1219
        event: 'flush',
2,224✔
1220
      });
2,224✔
1221
    });
2,224✔
1222
  }
2,224✔
1223

1224
  private prepareResourceDelete(event: IResourceDeleteEvent) {
117✔
1225
    let baseId: string;
2,492✔
1226
    let resourceType: BaseNodeResourceType | undefined;
2,492✔
1227
    let resourceId: string | undefined;
2,492✔
1228
    switch (event.name) {
2,492✔
1229
      case Events.BASE_DELETE:
2,492✔
1230
        baseId = event.payload.baseId;
162✔
1231
        break;
162✔
1232
      case Events.TABLE_DELETE:
2,492✔
1233
        baseId = event.payload.baseId;
2,299✔
1234
        resourceType = BaseNodeResourceType.Table;
2,299✔
1235
        resourceId = event.payload.tableId;
2,299✔
1236
        break;
2,299✔
1237
      case Events.WORKFLOW_DELETE:
2,492!
NEW
1238
        baseId = event.payload.baseId;
×
NEW
1239
        resourceType = BaseNodeResourceType.Workflow;
×
NEW
1240
        resourceId = event.payload.workflowId;
×
NEW
1241
        break;
×
1242
      case Events.DASHBOARD_DELETE:
2,492!
1243
        baseId = event.payload.baseId;
16✔
1244
        resourceType = BaseNodeResourceType.Dashboard;
16✔
1245
        resourceId = event.payload.dashboardId;
16✔
1246
        break;
16✔
1247
      case Events.APP_DELETE:
2,492!
NEW
1248
        baseId = event.payload.baseId;
×
NEW
1249
        resourceType = BaseNodeResourceType.App;
×
NEW
1250
        resourceId = event.payload.appId;
×
NEW
1251
        break;
×
1252
      case Events.BASE_FOLDER_DELETE:
2,492!
NEW
1253
        baseId = event.payload.baseId;
×
NEW
1254
        resourceType = BaseNodeResourceType.Folder;
×
NEW
1255
        resourceId = event.payload.folderId;
×
NEW
1256
        break;
×
1257
    }
2,492✔
1258
    return {
2,492✔
1259
      baseId,
2,492✔
1260
      resourceType,
2,492✔
1261
      resourceId,
2,492✔
1262
    };
2,492✔
1263
  }
2,492✔
1264

1265
  private async getMaxOrder(baseId: string, parentId?: string | null) {
117✔
1266
    const prisma = this.prismaService.txClient();
4,761✔
1267
    const aggregate = await prisma.baseNode.aggregate({
4,761✔
1268
      where: { baseId, parentId },
4,761✔
1269
      _max: { order: true },
4,761✔
1270
    });
4,761✔
1271

1272
    return aggregate._max.order ?? 0;
4,757✔
1273
  }
4,761✔
1274

1275
  private async shuffleOrders(baseId: string, parentId: string | null) {
117✔
NEW
1276
    const prisma = this.prismaService.txClient();
×
NEW
1277
    const siblings = await prisma.baseNode.findMany({
×
NEW
1278
      where: { baseId, parentId },
×
NEW
1279
      orderBy: { order: 'asc' },
×
NEW
1280
    });
×
1281

NEW
1282
    for (const [index, sibling] of siblings.entries()) {
×
NEW
1283
      await prisma.baseNode.update({
×
NEW
1284
        where: { id: sibling.id },
×
NEW
1285
        data: { order: index + 10, lastModifiedBy: this.userId },
×
NEW
1286
      });
×
NEW
1287
    }
×
NEW
1288
  }
×
1289

1290
  private async getParentNodeOrThrow(id: string) {
117✔
1291
    const entry = await this.prismaService.baseNode.findFirst({
27✔
1292
      where: { id },
27✔
1293
      select: {
27✔
1294
        id: true,
27✔
1295
        parentId: true,
27✔
1296
        resourceType: true,
27✔
1297
        resourceId: true,
27✔
1298
      },
27✔
1299
    });
27✔
1300
    if (!entry) {
27✔
1301
      throw new CustomHttpException('Base node not found', HttpErrorCode.NOT_FOUND, {
1✔
1302
        localization: {
1✔
1303
          i18nKey: 'httpErrors.baseNode.notFound',
1✔
1304
        },
1✔
1305
      });
1✔
1306
    }
1✔
1307
    return entry;
26✔
1308
  }
26✔
1309

1310
  private async assertFolderDepth(baseId: string, id: string) {
117✔
1311
    const folderDepth = await this.getFolderDepth(baseId, id);
28✔
1312
    if (folderDepth >= maxFolderDepth) {
28✔
1313
      throw new CustomHttpException('Folder depth limit exceeded', HttpErrorCode.VALIDATION_ERROR, {
4✔
1314
        localization: {
4✔
1315
          i18nKey: 'httpErrors.baseNode.folderDepthLimitExceeded',
4✔
1316
        },
4✔
1317
      });
4✔
1318
    }
4✔
1319
  }
28✔
1320

1321
  private async getFolderDepth(baseId: string, id: string) {
117✔
1322
    const prisma = this.prismaService.txClient();
28✔
1323
    const allFolders = await prisma.baseNode.findMany({
28✔
1324
      where: { baseId, resourceType: BaseNodeResourceType.Folder },
28✔
1325
      select: { id: true, parentId: true },
28✔
1326
    });
28✔
1327

1328
    let depth = 0;
28✔
1329
    if (allFolders.length === 0) {
28!
NEW
1330
      return depth;
×
NEW
1331
    }
×
1332

1333
    const folderMap = keyBy(allFolders, 'id');
28✔
1334
    let current = id;
28✔
1335
    while (current) {
28✔
1336
      depth++;
32✔
1337
      const folder = folderMap[current];
32✔
1338
      if (!folder) {
32!
NEW
1339
        throw new CustomHttpException('Folder not found', HttpErrorCode.NOT_FOUND, {
×
NEW
1340
          localization: {
×
NEW
1341
            i18nKey: 'httpErrors.baseNode.folderNotFound',
×
NEW
1342
          },
×
NEW
1343
        });
×
NEW
1344
      }
×
1345
      if (folder.parentId === id) {
32!
NEW
1346
        throw new CustomHttpException(
×
NEW
1347
          'A folder cannot be its own parent',
×
NEW
1348
          HttpErrorCode.VALIDATION_ERROR,
×
NEW
1349
          {
×
NEW
1350
            localization: {
×
NEW
1351
              i18nKey: 'httpErrors.baseNode.circularReference',
×
NEW
1352
            },
×
NEW
1353
          }
×
1354
        );
NEW
1355
      }
×
1356
      current = folder.parentId ?? '';
32✔
1357
    }
32✔
1358
    return depth;
28✔
1359
  }
28✔
1360

1361
  private async isCircularReference(
117✔
1362
    baseId: string,
7✔
1363
    nodeId: string,
7✔
1364
    parentId: string
7✔
1365
  ): Promise<boolean> {
7✔
1366
    const knex = this.knex;
7✔
1367

1368
    // Non-recursive query: Start with the parent node
7✔
1369
    const nonRecursiveQuery = knex
7✔
1370
      .select('id', 'parent_id', 'base_id')
7✔
1371
      .from('base_node')
7✔
1372
      .where('id', parentId)
7✔
1373
      .andWhere('base_id', baseId);
7✔
1374

1375
    // Recursive query: Traverse up the parent chain
7✔
1376
    const recursiveQuery = knex
7✔
1377
      .select('bn.id', 'bn.parent_id', 'bn.base_id')
7✔
1378
      .from('base_node as bn')
7✔
1379
      .innerJoin('ancestors as a', function () {
7✔
1380
        // Join condition: bn.id = a.parent_id (get parent of current ancestor)
7✔
1381
        this.on('bn.id', '=', 'a.parent_id').andOn('bn.base_id', '=', knex.raw('?', [baseId]));
7✔
1382
      });
7✔
1383

1384
    // Combine non-recursive and recursive queries
7✔
1385
    const cteQuery = nonRecursiveQuery.union(recursiveQuery);
7✔
1386

1387
    // Build final query with recursive CTE
7✔
1388
    const finalQuery = knex
7✔
1389
      .withRecursive('ancestors', ['id', 'parent_id', 'base_id'], cteQuery)
7✔
1390
      .select('id')
7✔
1391
      .from('ancestors')
7✔
1392
      .where('id', nodeId)
7✔
1393
      .limit(1)
7✔
1394
      .toQuery();
7✔
1395

1396
    // Execute query
7✔
1397
    const result = await this.prismaService
7✔
1398
      .txClient()
7✔
1399
      .$queryRawUnsafe<Array<{ id: string }>>(finalQuery);
7✔
1400

1401
    return result.length > 0;
7✔
1402
  }
7✔
1403

1404
  private async batchUpdateBaseNodes(data: { id: string; values: { [key: string]: unknown } }[]) {
117✔
1405
    const sql = this.buildBatchUpdateSql(data);
2,059✔
1406
    if (!sql) {
2,059!
NEW
1407
      return;
×
NEW
1408
    }
×
1409
    await this.prismaService.txClient().$executeRawUnsafe(sql);
2,059✔
1410
  }
2,058✔
1411

1412
  buildBatchUpdateSql(data: { id: string; values: { [key: string]: unknown } }[]): string | null {
117✔
1413
    if (data.length === 0) {
2,068!
1414
      return null;
1✔
1415
    }
1✔
1416

1417
    const caseStatements: Record<string, { when: string; then: unknown }[]> = {};
2,067✔
1418
    for (const { id, values } of data) {
2,068✔
1419
      for (const [key, value] of Object.entries(values)) {
12,055✔
1420
        if (!caseStatements[key]) {
24,102✔
1421
          caseStatements[key] = [];
4,130✔
1422
        }
4,130✔
1423
        caseStatements[key].push({ when: id, then: value });
24,102✔
1424
      }
24,102✔
1425
    }
12,055✔
1426

1427
    const updatePayload: Record<string, Knex.Raw> = {};
2,067✔
1428
    for (const [key, statements] of Object.entries(caseStatements)) {
2,068✔
1429
      if (statements.length === 0) {
4,130!
NEW
1430
        continue;
×
NEW
1431
      }
×
1432
      const column = snakeCase(key);
4,130✔
1433
      const whenClauses: string[] = [];
4,130✔
1434
      const caseBindings: unknown[] = [];
4,130✔
1435
      for (const { when, then } of statements) {
4,130✔
1436
        whenClauses.push('WHEN ?? = ? THEN ?');
24,102✔
1437
        caseBindings.push('id', when, then);
24,102✔
1438
      }
24,102✔
1439
      const caseExpression = `CASE ${whenClauses.join(' ')} ELSE ?? END`;
4,130✔
1440
      const rawExpression = this.knex.raw(caseExpression, [...caseBindings, column]);
4,130✔
1441
      updatePayload[column] = rawExpression;
4,130✔
1442
    }
4,130✔
1443

1444
    if (Object.keys(updatePayload).length === 0) {
2,068!
1445
      return null;
1✔
1446
    }
1✔
1447

1448
    const idsToUpdate = data.map((item) => item.id);
2,066✔
1449
    return this.knex('base_node').update(updatePayload).whereIn('id', idsToUpdate).toQuery();
2,066✔
1450
  }
2,066✔
1451
}
117✔
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