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

teableio / teable / 20135492339

11 Dec 2025 01:55PM UTC coverage: 71.773% (-0.02%) from 71.792%
20135492339

Pull #2236

github

web-flow
Merge 085ca6f8f into 600755edc
Pull Request #2236: feat: space layout

23046 of 25710 branches covered (89.64%)

54 of 92 new or added lines in 5 files covered. (58.7%)

3 existing lines in 1 file now uncovered.

58134 of 80997 relevant lines covered (71.77%)

4253.25 hits per line

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

77.94
/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts
1
/* eslint-disable @typescript-eslint/naming-convention */
8✔
2
/* eslint-disable sonarjs/no-duplicate-string */
8✔
3
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
4
import {
5
  canManageRole,
6
  getRandomString,
7
  HttpErrorCode,
8
  Role,
9
  type IBaseRole,
10
  type IRole,
11
} from '@teable/core';
12
import { PrismaService } from '@teable/db-main-prisma';
13
import type {
14
  AddBaseCollaboratorRo,
15
  AddSpaceCollaboratorRo,
16
  CollaboratorItem,
17
  IItemBaseCollaboratorUser,
18
  IListBaseCollaboratorUserRo,
19
} from '@teable/openapi';
20
import { CollaboratorType, PrincipalType } from '@teable/openapi';
21
import { Knex } from 'knex';
22
import { difference, keyBy, map } from 'lodash';
23
import { InjectModel } from 'nest-knexjs';
24
import { ClsService } from 'nestjs-cls';
25
import { CustomHttpException } from '../../custom.exception';
26
import { InjectDbProvider } from '../../db-provider/db.provider';
27
import { IDbProvider } from '../../db-provider/db.provider.interface';
28
import { EventEmitterService } from '../../event-emitter/event-emitter.service';
29
import {
30
  CollaboratorCreateEvent,
31
  CollaboratorDeleteEvent,
32
  Events,
33
} from '../../event-emitter/events';
34
import type { IClsStore } from '../../types/cls';
35
import { getMaxLevelRole } from '../../utils/get-max-level-role';
36
import { getPublicFullStorageUrl } from '../attachments/plugins/utils';
37

38
@Injectable()
39
export class CollaboratorService {
8✔
40
  constructor(
173✔
41
    private readonly prismaService: PrismaService,
173✔
42
    private readonly cls: ClsService<IClsStore>,
173✔
43
    private readonly eventEmitterService: EventEmitterService,
173✔
44
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
173✔
45
    @InjectDbProvider() private readonly dbProvider: IDbProvider
173✔
46
  ) {}
173✔
47

48
  async createSpaceCollaborator({
173✔
49
    collaborators,
119✔
50
    spaceId,
119✔
51
    role,
119✔
52
    createdBy,
119✔
53
  }: {
54
    collaborators: {
55
      principalId: string;
56
      principalType: PrincipalType;
57
    }[];
58
    spaceId: string;
59
    role: IRole;
60
    createdBy?: string;
61
  }) {
119✔
62
    const currentUserId = createdBy || this.cls.get('user.id');
119✔
63
    const exist = await this.prismaService.txClient().collaborator.count({
119✔
64
      where: {
119✔
65
        OR: collaborators.map((collaborator) => ({
119✔
66
          principalId: collaborator.principalId,
119✔
67
          principalType: collaborator.principalType,
119✔
68
        })),
119✔
69
        resourceId: spaceId,
119✔
70
        resourceType: CollaboratorType.Space,
119✔
71
      },
119✔
72
    });
119✔
73
    if (exist) {
119✔
74
      throw new CustomHttpException(
1✔
75
        'Collaborator has already existed in space',
1✔
76
        HttpErrorCode.VALIDATION_ERROR,
1✔
77
        {
1✔
78
          localization: {
1✔
79
            i18nKey: 'httpErrors.collaborator.alreadyExisted',
1✔
80
          },
1✔
81
        }
1✔
82
      );
83
    }
1✔
84
    // if has exist base collaborator, then delete it
118✔
85
    const bases = await this.prismaService.txClient().base.findMany({
118✔
86
      where: {
118✔
87
        spaceId,
118✔
88
        deletedTime: null,
118✔
89
      },
118✔
90
    });
118✔
91

92
    await this.prismaService.txClient().collaborator.deleteMany({
118✔
93
      where: {
118✔
94
        OR: collaborators.map((collaborator) => ({
118✔
95
          principalId: collaborator.principalId,
118✔
96
          principalType: collaborator.principalType,
118✔
97
        })),
118✔
98
        resourceId: { in: bases.map((base) => base.id) },
118✔
99
        resourceType: CollaboratorType.Base,
118✔
100
      },
118✔
101
    });
118✔
102

103
    await this.prismaService.txClient().collaborator.createMany({
118✔
104
      data: collaborators.map((collaborator) => ({
118✔
105
        id: getRandomString(16),
118✔
106
        resourceId: spaceId,
118✔
107
        resourceType: CollaboratorType.Space,
118✔
108
        roleName: role,
118✔
109
        principalId: collaborator.principalId,
118✔
110
        principalType: collaborator.principalType,
118✔
111
        createdBy: currentUserId!,
118✔
112
      })),
118✔
113
    });
118✔
114
    this.eventEmitterService.emitAsync(
118✔
115
      Events.COLLABORATOR_CREATE,
118✔
116
      new CollaboratorCreateEvent(spaceId)
118✔
117
    );
118
  }
118✔
119

120
  protected async getBaseCollaboratorBuilder(
173✔
121
    knex: Knex.QueryBuilder,
316✔
122
    baseId: string,
316✔
123
    options?: { includeSystem?: boolean; search?: string; type?: PrincipalType; role?: IRole[] }
316✔
124
  ) {
316✔
125
    const base = await this.prismaService
316✔
126
      .txClient()
316✔
127
      .base.findUniqueOrThrow({ select: { spaceId: true }, where: { id: baseId } });
316✔
128

129
    const builder = knex
316✔
130
      .from('collaborator')
316✔
131
      .leftJoin('users', 'collaborator.principal_id', 'users.id')
316✔
132
      .whereIn('collaborator.resource_id', [baseId, base.spaceId]);
316✔
133
    const { includeSystem, search, type, role } = options ?? {};
316✔
134
    if (!includeSystem) {
316✔
135
      builder.where((db) => {
25✔
136
        return db.whereNull('users.is_system').orWhere('users.is_system', false);
25✔
137
      });
25✔
138
    }
25✔
139
    if (search) {
316✔
140
      this.dbProvider.searchBuilder(builder, [
4✔
141
        ['users.name', search],
4✔
142
        ['users.email', search],
4✔
143
      ]);
4✔
144
    }
4✔
145

146
    if (role?.length) {
316✔
147
      builder.whereIn('collaborator.role_name', role);
×
148
    }
×
149
    if (type) {
316✔
150
      builder.where('collaborator.principal_type', type);
2✔
151
    }
2✔
152
  }
316✔
153

154
  async getTotalBase(
173✔
155
    baseId: string,
12✔
156
    options?: { includeSystem?: boolean; search?: string; type?: PrincipalType; role?: IRole[] }
12✔
157
  ) {
12✔
158
    const builder = this.knex.queryBuilder();
12✔
159
    await this.getBaseCollaboratorBuilder(builder, baseId, options);
12✔
160
    const res = await this.prismaService
12✔
161
      .txClient()
12✔
162
      .$queryRawUnsafe<
12✔
163
        { count: number }[]
164
      >(builder.select(this.knex.raw('COUNT(*) as count')).toQuery());
12✔
165
    return Number(res[0].count);
12✔
166
  }
12✔
167

168
  protected async getListByBaseBuilder(
173✔
169
    builder: Knex.QueryBuilder,
9✔
170
    options?: {
9✔
171
      includeSystem?: boolean;
172
      skip?: number;
173
      take?: number;
174
      search?: string;
175
      type?: PrincipalType;
176
      orderBy?: 'desc' | 'asc';
177
    }
9✔
178
  ) {
9✔
179
    const { skip = 0, take = 50 } = options ?? {};
9✔
180
    builder.offset(skip);
9✔
181
    builder.limit(take);
9✔
182
    builder.select({
9✔
183
      resource_id: 'collaborator.resource_id',
9✔
184
      role_name: 'collaborator.role_name',
9✔
185
      created_time: 'collaborator.created_time',
9✔
186
      resource_type: 'collaborator.resource_type',
9✔
187
      user_id: 'users.id',
9✔
188
      user_name: 'users.name',
9✔
189
      user_email: 'users.email',
9✔
190
      user_avatar: 'users.avatar',
9✔
191
      user_is_system: 'users.is_system',
9✔
192
    });
9✔
193
    builder.orderBy('collaborator.created_time', options?.orderBy ?? 'desc');
9✔
194
  }
9✔
195

196
  async getListByBase(
173✔
197
    baseId: string,
9✔
198
    options?: {
9✔
199
      includeSystem?: boolean;
200
      skip?: number;
201
      take?: number;
202
      search?: string;
203
      type?: PrincipalType;
204
      role?: IRole[];
205
    }
9✔
206
  ): Promise<CollaboratorItem[]> {
9✔
207
    const builder = this.knex.queryBuilder();
9✔
208
    builder.whereNotNull('users.id');
9✔
209
    await this.getBaseCollaboratorBuilder(builder, baseId, options);
9✔
210
    await this.getListByBaseBuilder(builder, options);
9✔
211
    const collaborators = await this.prismaService.txClient().$queryRawUnsafe<
9✔
212
      {
213
        resource_id: string;
214
        role_name: string;
215
        created_time: Date;
216
        resource_type: string;
217
        user_id: string;
218
        user_name: string;
219
        user_email: string;
220
        user_avatar: string;
221
        user_is_system: boolean | null;
222
      }[]
223
    >(builder.toQuery());
9✔
224

225
    return collaborators.map((collaborator) => ({
9✔
226
      type: PrincipalType.User,
20✔
227
      userId: collaborator.user_id,
20✔
228
      userName: collaborator.user_name,
20✔
229
      email: collaborator.user_email,
20✔
230
      avatar: collaborator.user_avatar ? getPublicFullStorageUrl(collaborator.user_avatar) : null,
20✔
231
      role: collaborator.role_name as IRole,
20✔
232
      createdTime: collaborator.created_time.toISOString(),
20✔
233
      resourceType: collaborator.resource_type as CollaboratorType,
20✔
234
      isSystem: collaborator.user_is_system || undefined,
20✔
235
    }));
20✔
236
  }
9✔
237

238
  async getUserCollaboratorsByTableId(
173✔
239
    tableId: string,
291✔
240
    query: {
291✔
241
      containsIn: {
242
        keys: ('id' | 'name' | 'email' | 'phone')[];
243
        values: string[];
244
      };
245
    }
291✔
246
  ) {
291✔
247
    const { baseId } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({
291✔
248
      select: { baseId: true },
291✔
249
      where: { id: tableId },
291✔
250
    });
291✔
251

252
    const builder = this.knex.queryBuilder();
291✔
253
    await this.getBaseCollaboratorBuilder(builder, baseId, {
291✔
254
      includeSystem: true,
291✔
255
    });
291✔
256
    if (query.containsIn) {
291✔
257
      builder.where((db) => {
291✔
258
        const keys = query.containsIn.keys;
291✔
259
        const values = query.containsIn.values;
291✔
260
        keys.forEach((key) => {
291✔
261
          db.orWhereIn('users.' + key, values);
1,164✔
262
        });
1,164✔
263
        return db;
291✔
264
      });
291✔
265
    }
291✔
266
    builder.whereNotNull('users.id');
291✔
267
    builder.select({
291✔
268
      id: 'users.id',
291✔
269
      name: 'users.name',
291✔
270
      email: 'users.email',
291✔
271
      avatar: 'users.avatar',
291✔
272
      isSystem: 'users.is_system',
291✔
273
    });
291✔
274

275
    return this.prismaService.txClient().$queryRawUnsafe<
291✔
276
      {
277
        id: string;
278
        name: string;
279
        email: string;
280
        avatar: string | null;
281
        isSystem: boolean | null;
282
      }[]
283
    >(builder.toQuery());
291✔
284
  }
291✔
285

286
  protected async getSpaceCollaboratorBuilder(
173✔
287
    knex: Knex.QueryBuilder,
36✔
288
    spaceId: string,
36✔
289
    options?: {
36✔
290
      includeSystem?: boolean;
291
      search?: string;
292
      includeBase?: boolean;
293
      type?: PrincipalType;
294
    }
36✔
295
  ): Promise<{
296
    builder: Knex.QueryBuilder;
297
    baseMap: Record<string, { name: string; id: string }>;
298
  }> {
36✔
299
    const { includeSystem, search, type, includeBase } = options ?? {};
36✔
300

301
    let baseIds: string[] = [];
36✔
302
    let baseMap: Record<string, { name: string; id: string }> = {};
36✔
303
    if (includeBase) {
36✔
304
      const bases = await this.prismaService.txClient().base.findMany({
20✔
305
        where: { spaceId, deletedTime: null, space: { deletedTime: null } },
20✔
306
      });
20✔
307
      baseIds = map(bases, 'id') as string[];
20✔
308
      baseMap = bases.reduce(
20✔
309
        (acc, base) => {
20✔
310
          acc[base.id] = { name: base.name, id: base.id };
14✔
311
          return acc;
14✔
312
        },
14✔
313
        {} as Record<string, { name: string; id: string }>
20✔
314
      );
315
    }
20✔
316

317
    const builder = knex
36✔
318
      .from('collaborator')
36✔
319
      .leftJoin('users', 'collaborator.principal_id', 'users.id');
36✔
320

321
    if (baseIds?.length) {
36✔
322
      builder.whereIn('collaborator.resource_id', [...baseIds, spaceId]);
14✔
323
    } else {
36✔
324
      builder.where('collaborator.resource_id', spaceId);
22✔
325
    }
22✔
326
    if (!includeSystem) {
36✔
327
      builder.where((db) => {
33✔
328
        return db.whereNull('users.is_system').orWhere('users.is_system', false);
33✔
329
      });
33✔
330
    }
33✔
331
    if (search) {
36✔
332
      this.dbProvider.searchBuilder(builder, [
3✔
333
        ['users.name', search],
3✔
334
        ['users.email', search],
3✔
335
      ]);
3✔
336
    }
3✔
337
    if (type) {
36✔
338
      builder.where('collaborator.principal_type', type);
×
339
    }
×
340
    return { builder, baseMap };
36✔
341
  }
36✔
342

343
  async getTotalSpace(
173✔
344
    spaceId: string,
×
345
    options?: {
×
346
      includeSystem?: boolean;
347
      includeBase?: boolean;
348
      search?: string;
349
      type?: PrincipalType;
350
    }
×
351
  ) {
×
352
    const builder = this.knex.queryBuilder();
×
353
    await this.getSpaceCollaboratorBuilder(builder, spaceId, options);
×
354
    const res = await this.prismaService
×
355
      .txClient()
×
356
      .$queryRawUnsafe<
×
357
        { count: number }[]
358
      >(builder.select(this.knex.raw('COUNT(*) as count')).toQuery());
×
359
    return Number(res[0].count);
×
360
  }
×
361

362
  async getSpaceCollaboratorStats(
173✔
363
    spaceId: string,
12✔
364
    options?: {
12✔
365
      includeSystem?: boolean;
366
      includeBase?: boolean;
367
      search?: string;
368
      type?: PrincipalType;
369
    }
12✔
370
  ) {
12✔
371
    // Get total count (existing logic)
12✔
372
    const builder = this.knex.queryBuilder();
12✔
373
    await this.getSpaceCollaboratorBuilder(builder, spaceId, options);
12✔
374
    const res = await this.prismaService
12✔
375
      .txClient()
12✔
376
      .$queryRawUnsafe<
12✔
377
        { count: number }[]
378
      >(builder.select(this.knex.raw('COUNT(*) as count')).toQuery());
12✔
379
    const total = Number(res[0].count);
12✔
380

381
    // Get unique total - distinct users across space and base collaborators
12✔
382
    const uniqBuilder = this.knex.queryBuilder();
12✔
383
    await this.getSpaceCollaboratorBuilder(uniqBuilder, spaceId, { ...options, includeBase: true });
12✔
384
    const uniqRes = await this.prismaService
12✔
385
      .txClient()
12✔
386
      .$queryRawUnsafe<
12✔
387
        { count: number }[]
388
      >(uniqBuilder.select(this.knex.raw('COUNT(DISTINCT users.id) as count')).toQuery());
12✔
389
    const uniqTotal = Number(uniqRes[0].count);
12✔
390

391
    return {
12✔
392
      total,
12✔
393
      uniqTotal,
12✔
394
    };
12✔
395
  }
12✔
396

397
  // eslint-disable-next-line sonarjs/no-identical-functions
173✔
398
  protected async getListBySpaceBuilder(
173✔
399
    builder: Knex.QueryBuilder,
12✔
400
    options?: {
12✔
401
      includeSystem?: boolean;
402
      includeBase?: boolean;
403
      skip?: number;
404
      take?: number;
405
      search?: string;
406
      type?: PrincipalType;
407
      orderBy?: 'desc' | 'asc';
408
    }
12✔
409
  ) {
12✔
410
    const { skip = 0, take = 50 } = options ?? {};
12✔
411
    builder.offset(skip);
12✔
412
    builder.limit(take);
12✔
413
    builder.select({
12✔
414
      resource_id: 'collaborator.resource_id',
12✔
415
      role_name: 'collaborator.role_name',
12✔
416
      created_time: 'collaborator.created_time',
12✔
417
      resource_type: 'collaborator.resource_type',
12✔
418
      user_id: 'users.id',
12✔
419
      user_name: 'users.name',
12✔
420
      user_email: 'users.email',
12✔
421
      user_avatar: 'users.avatar',
12✔
422
      user_is_system: 'users.is_system',
12✔
423
    });
12✔
424
    builder.orderBy('collaborator.created_time', options?.orderBy ?? 'desc');
12✔
425
  }
12✔
426

427
  async getListBySpace(
173✔
428
    spaceId: string,
12✔
429
    options?: {
12✔
430
      includeSystem?: boolean;
431
      includeBase?: boolean;
432
      skip?: number;
433
      take?: number;
434
      search?: string;
435
      type?: PrincipalType;
436
      orderBy?: 'desc' | 'asc';
437
    }
12✔
438
  ): Promise<CollaboratorItem[]> {
12✔
439
    const isCommunityEdition =
12✔
440
      process.env.NEXT_BUILD_ENV_EDITION?.toUpperCase() !== 'EE' &&
12✔
441
      process.env.NEXT_BUILD_ENV_EDITION?.toUpperCase() !== 'CLOUD';
12✔
442

443
    const builder = this.knex.queryBuilder();
12✔
444
    builder.whereNotNull('users.id');
12✔
445
    const { baseMap } = await this.getSpaceCollaboratorBuilder(builder, spaceId, options);
12✔
446
    await this.getListBySpaceBuilder(builder, options);
12✔
447
    const collaborators = await this.prismaService.txClient().$queryRawUnsafe<
12✔
448
      {
449
        resource_id: string;
450
        role_name: string;
451
        created_time: Date;
452
        resource_type: string;
453
        user_id: string;
454
        user_name: string;
455
        user_email: string;
456
        user_avatar: string;
457
        user_is_system: boolean | null;
458
      }[]
459
    >(builder.toQuery());
12✔
460

461
    // Get billable users if not community edition and includeBase is true
12✔
462
    let billableUserIds = new Set<string>();
12✔
463
    if (!isCommunityEdition && options?.includeBase) {
12✔
464
      const billableRoles = ['owner', 'creator', 'editor'];
×
465
      const billableBuilder = this.knex.queryBuilder();
×
466
      await this.getSpaceCollaboratorBuilder(billableBuilder, spaceId, {
×
467
        ...options,
×
468
        includeBase: true,
×
469
      });
×
470
      billableBuilder.whereIn('collaborator.role_name', billableRoles);
×
471
      billableBuilder.select({ user_id: 'users.id' });
×
472

473
      const billableUsers = await this.prismaService
×
474
        .txClient()
×
475
        .$queryRawUnsafe<{ user_id: string }[]>(billableBuilder.toQuery());
×
476

477
      billableUserIds = new Set(billableUsers.map((u) => u.user_id));
×
478
    }
×
479

480
    return collaborators.map((collaborator) => {
12✔
481
      const billableRoles = ['owner', 'creator', 'editor'];
24✔
482

483
      return {
24✔
484
        type: PrincipalType.User,
24✔
485
        resourceType: collaborator.resource_type as CollaboratorType,
24✔
486
        userId: collaborator.user_id,
24✔
487
        userName: collaborator.user_name,
24✔
488
        email: collaborator.user_email,
24✔
489
        avatar: collaborator.user_avatar ? getPublicFullStorageUrl(collaborator.user_avatar) : null,
24✔
490
        role: collaborator.role_name as IRole,
24✔
491
        createdTime: collaborator.created_time.toISOString(),
24✔
492
        base: baseMap[collaborator.resource_id],
24✔
493
        billable:
24✔
494
          !isCommunityEdition &&
24✔
495
          (billableRoles.includes(collaborator.role_name) ||
×
496
            billableUserIds.has(collaborator.user_id)),
×
497
      };
24✔
498
    });
24✔
499
  }
12✔
500

501
  private async getOperatorCollaborators({
173✔
502
    targetPrincipalId,
23✔
503
    currentPrincipalId,
23✔
504
    resourceId,
23✔
505
    resourceType,
23✔
506
  }: {
507
    resourceId: string;
508
    resourceType: CollaboratorType;
509
    targetPrincipalId: string;
510
    currentPrincipalId: string;
511
  }) {
23✔
512
    const currentUserWhere: {
23✔
513
      principalId: string;
514
      resourceId: string | Record<string, string[]>;
515
    } = {
23✔
516
      principalId: currentPrincipalId,
23✔
517
      resourceId,
23✔
518
    };
23✔
519
    const targetUserWhere: {
23✔
520
      principalId: string;
521
      resourceId: string | Record<string, string[]>;
522
    } = {
23✔
523
      principalId: targetPrincipalId,
23✔
524
      resourceId,
23✔
525
    };
23✔
526

527
    // for space user delete base collaborator
23✔
528
    if (resourceType === CollaboratorType.Base) {
23✔
529
      const spaceId = await this.prismaService
16✔
530
        .txClient()
16✔
531
        .base.findUniqueOrThrow({
16✔
532
          where: { id: resourceId, deletedTime: null },
16✔
533
          select: { spaceId: true },
16✔
534
        })
16✔
535
        .then((base) => base.spaceId);
16✔
536
      currentUserWhere.resourceId = { in: [resourceId, spaceId] };
16✔
537
    }
16✔
538
    const colls = await this.prismaService.txClient().collaborator.findMany({
23✔
539
      where: {
23✔
540
        OR: [currentUserWhere, targetUserWhere],
23✔
541
      },
23✔
542
    });
23✔
543

544
    const currentColl = colls.find((coll) => coll.principalId === currentPrincipalId);
23✔
545
    const targetColl = colls.find((coll) => coll.principalId === targetPrincipalId);
23✔
546
    if (!currentColl || !targetColl) {
23✔
547
      throw new CustomHttpException(
×
548
        'User not found in collaborator',
×
549
        HttpErrorCode.VALIDATION_ERROR,
×
550
        {
×
551
          localization: {
×
552
            i18nKey: 'httpErrors.collaborator.userNotFoundInCollaborator',
×
553
          },
×
554
        }
×
555
      );
556
    }
×
557
    return { currentColl, targetColl };
23✔
558
  }
23✔
559

560
  async isUniqueOwnerUser(spaceId: string, userId: string) {
173✔
561
    const builder = this.knex('collaborator')
8✔
562
      .leftJoin('users', 'collaborator.principal_id', 'users.id')
8✔
563
      .where('collaborator.resource_id', spaceId)
8✔
564
      .where('collaborator.resource_type', CollaboratorType.Space)
8✔
565
      .where('collaborator.role_name', Role.Owner)
8✔
566
      .where('users.is_system', null)
8✔
567
      .where('users.deleted_time', null)
8✔
568
      .where('users.deactivated_time', null)
8✔
569
      .select('collaborator.principal_id');
8✔
570
    const collaborators = await this.prismaService.txClient().$queryRawUnsafe<
8✔
571
      {
572
        principal_id: string;
573
      }[]
574
    >(builder.toQuery());
8✔
575
    return collaborators.length === 1 && collaborators[0].principal_id === userId;
8✔
576
  }
8✔
577

578
  async deleteCollaborator({
173✔
579
    resourceId,
12✔
580
    resourceType,
12✔
581
    principalId,
12✔
582
    principalType,
12✔
583
  }: {
584
    principalId: string;
585
    principalType: PrincipalType;
586
    resourceId: string;
587
    resourceType: CollaboratorType;
588
  }) {
12✔
589
    const currentUserId = this.cls.get('user.id');
12✔
590
    const { currentColl, targetColl } = await this.getOperatorCollaborators({
12✔
591
      currentPrincipalId: currentUserId,
12✔
592
      targetPrincipalId: principalId,
12✔
593
      resourceId,
12✔
594
      resourceType,
12✔
595
    });
12✔
596

597
    // validate user can operator target user
12✔
598
    if (
12✔
599
      currentUserId !== principalId &&
12✔
600
      currentColl.roleName !== Role.Owner &&
12✔
601
      !canManageRole(currentColl.roleName as IRole, targetColl.roleName)
4✔
602
    ) {
12✔
603
      throw new CustomHttpException(
2✔
604
        'You do not have permission to delete this collaborator',
2✔
605
        HttpErrorCode.RESTRICTED_RESOURCE,
2✔
606
        {
2✔
607
          localization: {
2✔
608
            i18nKey: 'httpErrors.collaborator.noPermissionToDelete',
2✔
609
          },
2✔
610
        }
2✔
611
      );
612
    }
2✔
613
    const result = await this.prismaService.txClient().collaborator.delete({
10✔
614
      where: {
10✔
615
        // eslint-disable-next-line @typescript-eslint/naming-convention
10✔
616
        resourceType_resourceId_principalId_principalType: {
10✔
617
          resourceId: resourceId,
10✔
618
          resourceType: resourceType,
10✔
619
          principalId,
10✔
620
          principalType,
10✔
621
        },
10✔
622
      },
10✔
623
    });
10✔
624
    let spaceId: string = resourceId;
10✔
625
    if (resourceType === CollaboratorType.Base) {
11✔
626
      const space = await this.prismaService
7✔
627
        .txClient()
7✔
628
        .base.findUniqueOrThrow({ where: { id: resourceId }, select: { spaceId: true } });
7✔
629
      spaceId = space.spaceId;
7✔
630
    }
7✔
631
    this.eventEmitterService.emitAsync(
10✔
632
      Events.COLLABORATOR_DELETE,
10✔
633
      new CollaboratorDeleteEvent(spaceId)
10✔
634
    );
635
    return result;
10✔
636
  }
10✔
637

638
  async updateCollaborator({
173✔
639
    role,
11✔
640
    principalId,
11✔
641
    principalType,
11✔
642
    resourceId,
11✔
643
    resourceType,
11✔
644
  }: {
645
    role: IRole;
646
    principalId: string;
647
    principalType: PrincipalType;
648
    resourceId: string;
649
    resourceType: CollaboratorType;
650
  }) {
11✔
651
    const currentUserId = this.cls.get('user.id');
11✔
652
    const { currentColl, targetColl } = await this.getOperatorCollaborators({
11✔
653
      currentPrincipalId: currentUserId,
11✔
654
      targetPrincipalId: principalId,
11✔
655
      resourceId,
11✔
656
      resourceType,
11✔
657
    });
11✔
658

659
    // validate user can operator target user
11✔
660
    if (
11✔
661
      currentUserId !== principalId &&
11✔
662
      currentColl.roleName !== targetColl.roleName &&
11✔
663
      !canManageRole(currentColl.roleName as IRole, targetColl.roleName)
6✔
664
    ) {
11✔
665
      throw new CustomHttpException(
1✔
666
        `You do not have permission to operator this collaborator: ${principalId}`,
1✔
667
        HttpErrorCode.RESTRICTED_RESOURCE,
1✔
668
        {
1✔
669
          localization: {
1✔
670
            i18nKey: 'httpErrors.collaborator.noPermissionToUpdate',
1✔
671
          },
1✔
672
        }
1✔
673
      );
674
    }
1✔
675

676
    // validate user can operator target role
10✔
677
    if (role !== currentColl.roleName && !canManageRole(currentColl.roleName as IRole, role)) {
11✔
678
      throw new CustomHttpException(
2✔
679
        `You do not have permission to operator this role: ${role}`,
2✔
680
        HttpErrorCode.RESTRICTED_RESOURCE,
2✔
681
        {
2✔
682
          localization: {
2✔
683
            i18nKey: 'httpErrors.collaborator.noPermissionToOperateRole',
2✔
684
          },
2✔
685
        }
2✔
686
      );
687
    }
2✔
688

689
    return this.prismaService.txClient().collaborator.updateMany({
8✔
690
      where: {
8✔
691
        resourceId: resourceId,
8✔
692
        resourceType: resourceType,
8✔
693
        principalId: principalId,
8✔
694
        principalType: principalType,
8✔
695
      },
8✔
696
      data: {
8✔
697
        roleName: role,
8✔
698
        lastModifiedBy: currentUserId,
8✔
699
      },
8✔
700
    });
8✔
701
  }
8✔
702

703
  async getCurrentUserCollaboratorsBaseAndSpaceArray(searchRoles?: IRole[]) {
173✔
704
    const userId = this.cls.get('user.id');
10✔
705
    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);
10✔
706
    const collaborators = await this.prismaService.txClient().collaborator.findMany({
10✔
707
      where: {
10✔
708
        principalId: { in: [userId, ...(departmentIds || [])] },
10✔
709
        ...(searchRoles && searchRoles.length > 0 ? { roleName: { in: searchRoles } } : {}),
10✔
710
      },
10✔
711
      select: {
10✔
712
        roleName: true,
10✔
713
        resourceId: true,
10✔
714
        resourceType: true,
10✔
715
      },
10✔
716
    });
10✔
717
    const roleMap: Record<string, IRole> = {};
10✔
718
    const baseIds = new Set<string>();
10✔
719
    const spaceIds = new Set<string>();
10✔
720
    collaborators.forEach(({ resourceId, roleName, resourceType }) => {
10✔
721
      if (!roleMap[resourceId] || canManageRole(roleName as IRole, roleMap[resourceId])) {
75✔
722
        roleMap[resourceId] = roleName as IRole;
75✔
723
      }
75✔
724
      if (resourceType === CollaboratorType.Base) {
75✔
725
        baseIds.add(resourceId);
50✔
726
      } else {
75✔
727
        spaceIds.add(resourceId);
25✔
728
      }
25✔
729
    });
75✔
730
    return {
10✔
731
      baseIds: Array.from(baseIds),
10✔
732
      spaceIds: Array.from(spaceIds),
10✔
733
      roleMap: roleMap,
10✔
734
    };
10✔
735
  }
10✔
736

737
  async createBaseCollaborator({
173✔
738
    collaborators,
57✔
739
    baseId,
57✔
740
    role,
57✔
741
    createdBy,
57✔
742
  }: {
743
    collaborators: {
744
      principalId: string;
745
      principalType: PrincipalType;
746
    }[];
747
    baseId: string;
748
    role: IBaseRole;
749
    createdBy?: string;
750
  }) {
57✔
751
    const currentUserId = createdBy || this.cls.get('user.id');
57✔
752
    const base = await this.prismaService.txClient().base.findUniqueOrThrow({
57✔
753
      where: { id: baseId },
57✔
754
    });
57✔
755
    const exist = await this.prismaService.txClient().collaborator.count({
57✔
756
      where: {
57✔
757
        OR: collaborators.map((collaborator) => ({
57✔
758
          principalId: collaborator.principalId,
57✔
759
          principalType: collaborator.principalType,
57✔
760
        })),
57✔
761
        resourceId: { in: [baseId, base.spaceId] },
57✔
762
      },
57✔
763
    });
57✔
764
    // if has exist space collaborator
57✔
765
    if (exist) {
57✔
766
      throw new CustomHttpException(
1✔
767
        'Collaborator has already existed in base',
1✔
768
        HttpErrorCode.VALIDATION_ERROR,
1✔
769
        {
1✔
770
          localization: {
1✔
771
            i18nKey: 'httpErrors.collaborator.alreadyExistedInBase',
1✔
772
          },
1✔
773
        }
1✔
774
      );
775
    }
1✔
776

777
    const res = await this.prismaService.txClient().collaborator.createMany({
56✔
778
      data: collaborators.map((collaborator) => ({
56✔
779
        id: getRandomString(16),
56✔
780
        resourceId: baseId,
56✔
781
        resourceType: CollaboratorType.Base,
56✔
782
        roleName: role,
56✔
783
        principalId: collaborator.principalId,
56✔
784
        principalType: collaborator.principalType,
56✔
785
        createdBy: currentUserId!,
56✔
786
      })),
56✔
787
    });
56✔
788
    this.eventEmitterService.emitAsync(
56✔
789
      Events.COLLABORATOR_CREATE,
56✔
790
      new CollaboratorCreateEvent(base.spaceId)
56✔
791
    );
792
    return res;
56✔
793
  }
56✔
794

795
  async getSharedBase() {
173✔
796
    const userId = this.cls.get('user.id');
×
797
    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);
×
798
    const coll = await this.prismaService.txClient().collaborator.findMany({
×
799
      where: {
×
800
        principalId: { in: [userId, ...(departmentIds || [])] },
×
801
        resourceType: CollaboratorType.Base,
×
802
      },
×
803
      select: {
×
804
        resourceId: true,
×
805
        roleName: true,
×
806
      },
×
807
    });
×
808

809
    if (!coll.length) {
×
810
      return [];
×
811
    }
×
812

813
    const roleMap: Record<string, IRole> = {};
×
814
    const baseIds = coll.map((c) => {
×
815
      if (!roleMap[c.resourceId] || canManageRole(c.roleName as IRole, roleMap[c.resourceId])) {
×
816
        roleMap[c.resourceId] = c.roleName as IRole;
×
817
      }
×
818
      return c.resourceId;
×
819
    });
×
820
    const bases = await this.prismaService.txClient().base.findMany({
×
821
      where: {
×
822
        id: { in: baseIds },
×
823
        deletedTime: null,
×
824
      },
×
825
      include: {
×
826
        space: {
×
827
          select: {
×
828
            name: true,
×
829
          },
×
830
        },
×
831
      },
×
832
    });
×
833

NEW
834
    const createdUserList = await this.prismaService.txClient().user.findMany({
×
NEW
835
      where: { id: { in: bases.map((base) => base.createdBy) } },
×
NEW
836
      select: { id: true, name: true, avatar: true },
×
NEW
837
    });
×
NEW
838
    const createdUserMap = keyBy(createdUserList, 'id');
×
839
    return bases.map((base) => ({
×
840
      id: base.id,
×
841
      name: base.name,
×
842
      role: roleMap[base.id],
×
843
      icon: base.icon,
×
844
      spaceId: base.spaceId,
×
845
      spaceName: base.space?.name,
×
846
      collaboratorType: CollaboratorType.Base,
×
NEW
847
      lastModifiedTime: base.lastModifiedTime?.toISOString(),
×
NEW
848
      createdTime: base.createdTime?.toISOString(),
×
NEW
849
      createdBy: base.createdBy,
×
NEW
850
      createdUser: {
×
NEW
851
        ...(createdUserMap[base.createdBy] ?? {}),
×
NEW
852
        avatar:
×
NEW
853
          createdUserMap[base.createdBy]?.avatar &&
×
NEW
854
          getPublicFullStorageUrl(createdUserMap[base.createdBy]?.avatar ?? ''),
×
NEW
855
      },
×
856
    }));
×
857
  }
×
858

859
  protected async validateCollaboratorUser(userIds: string[]) {
173✔
860
    const users = await this.prismaService.txClient().user.findMany({
×
861
      where: {
×
862
        id: { in: userIds },
×
863
        deletedTime: null,
×
864
      },
×
865
      select: {
×
866
        id: true,
×
867
      },
×
868
    });
×
869
    const diffIds = difference(
×
870
      userIds,
×
871
      users.map((u) => u.id)
×
872
    );
873
    if (diffIds.length > 0) {
×
874
      throw new CustomHttpException(
×
875
        `User not found: ${diffIds.join(', ')}`,
×
876
        HttpErrorCode.VALIDATION_ERROR,
×
877
        {
×
878
          localization: {
×
879
            i18nKey: 'httpErrors.collaborator.userNotFound',
×
880
            context: { userIds: diffIds.join(', ') },
×
881
          },
×
882
        }
×
883
      );
884
    }
×
885
  }
×
886

887
  async addSpaceCollaborators(spaceId: string, collaborator: AddSpaceCollaboratorRo) {
173✔
888
    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);
×
889
    await this.validateUserAddRole({
×
890
      departmentIds,
×
891
      userId: this.cls.get('user.id'),
×
892
      addRole: collaborator.role,
×
893
      resourceId: spaceId,
×
894
      resourceType: CollaboratorType.Space,
×
895
    });
×
896
    await this.validateCollaboratorUser(
×
897
      collaborator.collaborators
×
898
        .filter((c) => c.principalType === PrincipalType.User)
×
899
        .map((c) => c.principalId)
×
900
    );
901
    return this.createSpaceCollaborator({
×
902
      collaborators: collaborator.collaborators,
×
903
      spaceId,
×
904
      role: collaborator.role,
×
905
      createdBy: this.cls.get('user.id'),
×
906
    });
×
907
  }
×
908

909
  async addBaseCollaborators(baseId: string, collaborator: AddBaseCollaboratorRo) {
173✔
910
    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);
×
911
    await this.validateUserAddRole({
×
912
      departmentIds,
×
913
      userId: this.cls.get('user.id'),
×
914
      addRole: collaborator.role,
×
915
      resourceId: baseId,
×
916
      resourceType: CollaboratorType.Base,
×
917
    });
×
918
    await this.validateCollaboratorUser(
×
919
      collaborator.collaborators
×
920
        .filter((c) => c.principalType === PrincipalType.User)
×
921
        .map((c) => c.principalId)
×
922
    );
923
    return this.createBaseCollaborator({
×
924
      collaborators: collaborator.collaborators,
×
925
      baseId,
×
926
      role: collaborator.role,
×
927
      createdBy: this.cls.get('user.id'),
×
928
    });
×
929
  }
×
930

931
  async validateUserAddRole({
173✔
932
    departmentIds,
103✔
933
    userId,
103✔
934
    addRole,
103✔
935
    resourceId,
103✔
936
    resourceType,
103✔
937
  }: {
938
    departmentIds?: string[];
939
    userId: string;
940
    addRole: IRole;
941
    resourceId: string;
942
    resourceType: CollaboratorType;
943
  }) {
103✔
944
    let spaceId = resourceType === CollaboratorType.Space ? resourceId : '';
103✔
945
    if (resourceType === CollaboratorType.Base) {
103✔
946
      const base = await this.prismaService
62✔
947
        .txClient()
62✔
948
        .base.findFirstOrThrow({
62✔
949
          where: {
62✔
950
            id: resourceId,
62✔
951
            deletedTime: null,
62✔
952
          },
62✔
953
        })
62✔
954
        .catch(() => {
62✔
955
          throw new CustomHttpException('Base not found', HttpErrorCode.VALIDATION_ERROR, {
×
956
            localization: {
×
957
              i18nKey: 'httpErrors.collaborator.baseNotFound',
×
958
            },
×
959
          });
×
960
        });
×
961
      spaceId = base.spaceId;
62✔
962
    }
62✔
963
    const collaborators = await this.prismaService.txClient().collaborator.findMany({
103✔
964
      where: {
103✔
965
        principalId: departmentIds ? { in: [...departmentIds, userId] } : userId,
103✔
966
        resourceId: {
103✔
967
          in: [spaceId, resourceId],
103✔
968
        },
103✔
969
      },
103✔
970
    });
103✔
971
    if (collaborators.length === 0) {
103✔
972
      throw new CustomHttpException(
×
973
        'User not found in collaborator',
×
974
        HttpErrorCode.VALIDATION_ERROR,
×
975
        {
×
976
          localization: {
×
977
            i18nKey: 'httpErrors.collaborator.userNotFoundInCollaborator',
×
978
          },
×
979
        }
×
980
      );
981
    }
×
982
    const userRole = getMaxLevelRole(collaborators);
103✔
983

984
    if (userRole === addRole) {
103✔
985
      return;
10✔
986
    }
10✔
987
    if (!canManageRole(userRole, addRole)) {
100✔
988
      throw new CustomHttpException(
4✔
989
        `You do not have permission to add this role collaborator: ${addRole}`,
4✔
990
        HttpErrorCode.RESTRICTED_RESOURCE,
4✔
991
        {
4✔
992
          localization: {
4✔
993
            i18nKey: 'httpErrors.collaborator.noPermissionToAddRole',
4✔
994
          },
4✔
995
        }
4✔
996
      );
997
    }
4✔
998
  }
103✔
999

1000
  async getUserCollaboratorsTotal(baseId: string, options?: IListBaseCollaboratorUserRo) {
173✔
1001
    return this.getTotalBase(baseId, options);
3✔
1002
  }
3✔
1003

1004
  async getUserCollaborators(baseId: string, options?: IListBaseCollaboratorUserRo) {
173✔
1005
    const { skip = 0, take = 50 } = options ?? {};
4✔
1006
    const builder = this.knex.queryBuilder();
4✔
1007
    await this.getBaseCollaboratorBuilder(builder, baseId, options);
4✔
1008
    builder.whereNotNull('users.id');
4✔
1009
    builder.orderBy('collaborator.created_time', options?.orderBy ?? 'desc');
4✔
1010
    builder.offset(skip);
4✔
1011
    builder.limit(take);
4✔
1012
    builder.select({
4✔
1013
      id: 'users.id',
4✔
1014
      name: 'users.name',
4✔
1015
      email: 'users.email',
4✔
1016
      avatar: 'users.avatar',
4✔
1017
    });
4✔
1018
    const res = await this.prismaService
4✔
1019
      .txClient()
4✔
1020
      .$queryRawUnsafe<IItemBaseCollaboratorUser[]>(builder.toQuery());
4✔
1021
    return res.map((item) => ({
4✔
1022
      ...item,
8✔
1023
      avatar: item.avatar ? getPublicFullStorageUrl(item.avatar) : null,
8✔
1024
    }));
8✔
1025
  }
4✔
1026
}
173✔
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

© 2025 Coveralls, Inc