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

teableio / teable / 18671961261

21 Oct 2025 03:30AM UTC coverage: 75.089% (-0.005%) from 75.094%
18671961261

push

github

web-flow
fix: missing avatar when pasted user field records (#2003)

* fix: missing avatar when pasted user field records

* fix: admin users not appear refresh tips when authority updated

* fix: more friendly ui display

* fix: hidden or show all fields in share view

* fix: should't closed when click mask in add records dialog

* feat: get base collaborator users support filter by role

* fix: lint error

10031 of 10784 branches covered (93.02%)

8 of 10 new or added lines in 2 files covered. (80.0%)

2 existing lines in 1 file now uncovered.

50125 of 66754 relevant lines covered (75.09%)

4478.38 hits per line

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

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

30
@Injectable()
31
export class CollaboratorService {
2✔
32
  constructor(
133✔
33
    private readonly prismaService: PrismaService,
133✔
34
    private readonly cls: ClsService<IClsStore>,
133✔
35
    private readonly eventEmitterService: EventEmitterService,
133✔
36
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
133✔
37
    @InjectDbProvider() private readonly dbProvider: IDbProvider
133✔
38
  ) {}
133✔
39

40
  async createSpaceCollaborator({
133✔
41
    collaborators,
98✔
42
    spaceId,
98✔
43
    role,
98✔
44
    createdBy,
98✔
45
  }: {
46
    collaborators: {
47
      principalId: string;
48
      principalType: PrincipalType;
49
    }[];
50
    spaceId: string;
51
    role: IRole;
52
    createdBy?: string;
53
  }) {
98✔
54
    const currentUserId = createdBy || this.cls.get('user.id');
98✔
55
    const exist = await this.prismaService.txClient().collaborator.count({
98✔
56
      where: {
98✔
57
        OR: collaborators.map((collaborator) => ({
98✔
58
          principalId: collaborator.principalId,
98✔
59
          principalType: collaborator.principalType,
98✔
60
        })),
98✔
61
        resourceId: spaceId,
98✔
62
        resourceType: CollaboratorType.Space,
98✔
63
      },
98✔
64
    });
98✔
65
    if (exist) {
98✔
66
      throw new BadRequestException('has already existed in space');
1✔
67
    }
1✔
68
    // if has exist base collaborator, then delete it
97✔
69
    const bases = await this.prismaService.txClient().base.findMany({
97✔
70
      where: {
97✔
71
        spaceId,
97✔
72
        deletedTime: null,
97✔
73
      },
97✔
74
    });
97✔
75

76
    await this.prismaService.txClient().collaborator.deleteMany({
97✔
77
      where: {
97✔
78
        OR: collaborators.map((collaborator) => ({
97✔
79
          principalId: collaborator.principalId,
97✔
80
          principalType: collaborator.principalType,
97✔
81
        })),
97✔
82
        resourceId: { in: bases.map((base) => base.id) },
97✔
83
        resourceType: CollaboratorType.Base,
97✔
84
      },
97✔
85
    });
97✔
86

87
    await this.prismaService.txClient().collaborator.createMany({
97✔
88
      data: collaborators.map((collaborator) => ({
97✔
89
        id: getRandomString(16),
97✔
90
        resourceId: spaceId,
97✔
91
        resourceType: CollaboratorType.Space,
97✔
92
        roleName: role,
97✔
93
        principalId: collaborator.principalId,
97✔
94
        principalType: collaborator.principalType,
97✔
95
        createdBy: currentUserId!,
97✔
96
      })),
97✔
97
    });
97✔
98
    this.eventEmitterService.emitAsync(
97✔
99
      Events.COLLABORATOR_CREATE,
97✔
100
      new CollaboratorCreateEvent(spaceId)
97✔
101
    );
102
  }
97✔
103

104
  protected async getBaseCollaboratorBuilder(
133✔
105
    knex: Knex.QueryBuilder,
269✔
106
    baseId: string,
269✔
107
    options?: { includeSystem?: boolean; search?: string; type?: PrincipalType; role?: IRole[] }
269✔
108
  ) {
269✔
109
    const base = await this.prismaService
269✔
110
      .txClient()
269✔
111
      .base.findUniqueOrThrow({ select: { spaceId: true }, where: { id: baseId } });
269✔
112

113
    const builder = knex
269✔
114
      .from('collaborator')
269✔
115
      .leftJoin('users', 'collaborator.principal_id', 'users.id')
269✔
116
      .whereIn('collaborator.resource_id', [baseId, base.spaceId]);
269✔
117
    const { includeSystem, search, type, role } = options ?? {};
269✔
118
    if (!includeSystem) {
269✔
119
      builder.where((db) => {
25✔
120
        return db.whereNull('users.is_system').orWhere('users.is_system', false);
25✔
121
      });
25✔
122
    }
25✔
123
    if (search) {
269✔
124
      this.dbProvider.searchBuilder(builder, [
4✔
125
        ['users.name', search],
4✔
126
        ['users.email', search],
4✔
127
      ]);
4✔
128
    }
4✔
129

130
    if (role?.length) {
269✔
NEW
131
      builder.whereIn('collaborator.role_name', role);
×
NEW
132
    }
×
133
    if (type) {
269✔
134
      builder.where('collaborator.principal_type', type);
2✔
135
    }
2✔
136
  }
269✔
137

138
  async getTotalBase(
133✔
139
    baseId: string,
12✔
140
    options?: { includeSystem?: boolean; search?: string; type?: PrincipalType; role?: IRole[] }
12✔
141
  ) {
12✔
142
    const builder = this.knex.queryBuilder();
12✔
143
    await this.getBaseCollaboratorBuilder(builder, baseId, options);
12✔
144
    const res = await this.prismaService
12✔
145
      .txClient()
12✔
146
      .$queryRawUnsafe<
12✔
147
        { count: number }[]
148
      >(builder.select(this.knex.raw('COUNT(*) as count')).toQuery());
12✔
149
    return Number(res[0].count);
12✔
150
  }
12✔
151

152
  protected async getListByBaseBuilder(
133✔
153
    builder: Knex.QueryBuilder,
9✔
154
    options?: {
9✔
155
      includeSystem?: boolean;
156
      skip?: number;
157
      take?: number;
158
      search?: string;
159
      type?: PrincipalType;
160
      orderBy?: 'desc' | 'asc';
161
    }
9✔
162
  ) {
9✔
163
    const { skip = 0, take = 50 } = options ?? {};
9✔
164
    builder.offset(skip);
9✔
165
    builder.limit(take);
9✔
166
    builder.select({
9✔
167
      resource_id: 'collaborator.resource_id',
9✔
168
      role_name: 'collaborator.role_name',
9✔
169
      created_time: 'collaborator.created_time',
9✔
170
      resource_type: 'collaborator.resource_type',
9✔
171
      user_id: 'users.id',
9✔
172
      user_name: 'users.name',
9✔
173
      user_email: 'users.email',
9✔
174
      user_avatar: 'users.avatar',
9✔
175
      user_is_system: 'users.is_system',
9✔
176
    });
9✔
177
    builder.orderBy('collaborator.created_time', options?.orderBy ?? 'desc');
9✔
178
  }
9✔
179

180
  async getListByBase(
133✔
181
    baseId: string,
9✔
182
    options?: {
9✔
183
      includeSystem?: boolean;
184
      skip?: number;
185
      take?: number;
186
      search?: string;
187
      type?: PrincipalType;
188
      role?: IRole[];
189
    }
9✔
190
  ): Promise<CollaboratorItem[]> {
9✔
191
    const builder = this.knex.queryBuilder();
9✔
192
    builder.whereNotNull('users.id');
9✔
193
    await this.getBaseCollaboratorBuilder(builder, baseId, options);
9✔
194
    await this.getListByBaseBuilder(builder, options);
9✔
195
    const collaborators = await this.prismaService.txClient().$queryRawUnsafe<
9✔
196
      {
197
        resource_id: string;
198
        role_name: string;
199
        created_time: Date;
200
        resource_type: string;
201
        user_id: string;
202
        user_name: string;
203
        user_email: string;
204
        user_avatar: string;
205
        user_is_system: boolean | null;
206
      }[]
207
    >(builder.toQuery());
9✔
208

209
    return collaborators.map((collaborator) => ({
9✔
210
      type: PrincipalType.User,
20✔
211
      userId: collaborator.user_id,
20✔
212
      userName: collaborator.user_name,
20✔
213
      email: collaborator.user_email,
20✔
214
      avatar: collaborator.user_avatar ? getPublicFullStorageUrl(collaborator.user_avatar) : null,
20✔
215
      role: collaborator.role_name as IRole,
20✔
216
      createdTime: collaborator.created_time.toISOString(),
20✔
217
      resourceType: collaborator.resource_type as CollaboratorType,
20✔
218
      isSystem: collaborator.user_is_system || undefined,
20✔
219
    }));
20✔
220
  }
9✔
221

222
  async getUserCollaboratorsByTableId(
133✔
223
    tableId: string,
244✔
224
    query: {
244✔
225
      containsIn: {
226
        keys: ('id' | 'name' | 'email' | 'phone')[];
227
        values: string[];
228
      };
229
    }
244✔
230
  ) {
244✔
231
    const { baseId } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({
244✔
232
      select: { baseId: true },
244✔
233
      where: { id: tableId },
244✔
234
    });
244✔
235

236
    const builder = this.knex.queryBuilder();
244✔
237
    await this.getBaseCollaboratorBuilder(builder, baseId, {
244✔
238
      includeSystem: true,
244✔
239
    });
244✔
240
    if (query.containsIn) {
244✔
241
      builder.where((db) => {
244✔
242
        const keys = query.containsIn.keys;
244✔
243
        const values = query.containsIn.values;
244✔
244
        keys.forEach((key) => {
244✔
245
          db.orWhereIn('users.' + key, values);
976✔
246
        });
976✔
247
        return db;
244✔
248
      });
244✔
249
    }
244✔
250
    builder.whereNotNull('users.id');
244✔
251
    builder.select({
244✔
252
      id: 'users.id',
244✔
253
      name: 'users.name',
244✔
254
      email: 'users.email',
244✔
255
      avatar: 'users.avatar',
244✔
256
      isSystem: 'users.is_system',
244✔
257
    });
244✔
258

259
    return this.prismaService.txClient().$queryRawUnsafe<
244✔
260
      {
261
        id: string;
262
        name: string;
263
        email: string;
264
        avatar: string | null;
265
        isSystem: boolean | null;
266
      }[]
267
    >(builder.toQuery());
244✔
268
  }
244✔
269

270
  protected async getSpaceCollaboratorBuilder(
133✔
271
    knex: Knex.QueryBuilder,
36✔
272
    spaceId: string,
36✔
273
    options?: {
36✔
274
      includeSystem?: boolean;
275
      search?: string;
276
      includeBase?: boolean;
277
      type?: PrincipalType;
278
    }
36✔
279
  ): Promise<{
280
    builder: Knex.QueryBuilder;
281
    baseMap: Record<string, { name: string; id: string }>;
282
  }> {
36✔
283
    const { includeSystem, search, type, includeBase } = options ?? {};
36✔
284

285
    let baseIds: string[] = [];
36✔
286
    let baseMap: Record<string, { name: string; id: string }> = {};
36✔
287
    if (includeBase) {
36✔
288
      const bases = await this.prismaService.txClient().base.findMany({
20✔
289
        where: { spaceId, deletedTime: null, space: { deletedTime: null } },
20✔
290
      });
20✔
291
      baseIds = map(bases, 'id') as string[];
20✔
292
      baseMap = bases.reduce(
20✔
293
        (acc, base) => {
20✔
294
          acc[base.id] = { name: base.name, id: base.id };
14✔
295
          return acc;
14✔
296
        },
14✔
297
        {} as Record<string, { name: string; id: string }>
20✔
298
      );
299
    }
20✔
300

301
    const builder = knex
36✔
302
      .from('collaborator')
36✔
303
      .leftJoin('users', 'collaborator.principal_id', 'users.id');
36✔
304

305
    if (baseIds?.length) {
36✔
306
      builder.whereIn('collaborator.resource_id', [...baseIds, spaceId]);
14✔
307
    } else {
36✔
308
      builder.where('collaborator.resource_id', spaceId);
22✔
309
    }
22✔
310
    if (!includeSystem) {
36✔
311
      builder.where((db) => {
33✔
312
        return db.whereNull('users.is_system').orWhere('users.is_system', false);
33✔
313
      });
33✔
314
    }
33✔
315
    if (search) {
36✔
316
      this.dbProvider.searchBuilder(builder, [
3✔
317
        ['users.name', search],
3✔
318
        ['users.email', search],
3✔
319
      ]);
3✔
320
    }
3✔
321
    if (type) {
36✔
322
      builder.where('collaborator.principal_type', type);
×
323
    }
×
324
    return { builder, baseMap };
36✔
325
  }
36✔
326

327
  async getTotalSpace(
133✔
328
    spaceId: string,
×
329
    options?: {
×
330
      includeSystem?: boolean;
331
      includeBase?: boolean;
332
      search?: string;
333
      type?: PrincipalType;
334
    }
×
335
  ) {
×
336
    const builder = this.knex.queryBuilder();
×
337
    await this.getSpaceCollaboratorBuilder(builder, spaceId, options);
×
338
    const res = await this.prismaService
×
339
      .txClient()
×
340
      .$queryRawUnsafe<
×
341
        { count: number }[]
342
      >(builder.select(this.knex.raw('COUNT(*) as count')).toQuery());
×
343
    return Number(res[0].count);
×
344
  }
×
345

346
  async getSpaceCollaboratorStats(
133✔
347
    spaceId: string,
12✔
348
    options?: {
12✔
349
      includeSystem?: boolean;
350
      includeBase?: boolean;
351
      search?: string;
352
      type?: PrincipalType;
353
    }
12✔
354
  ) {
12✔
355
    // Get total count (existing logic)
12✔
356
    const builder = this.knex.queryBuilder();
12✔
357
    await this.getSpaceCollaboratorBuilder(builder, spaceId, options);
12✔
358
    const res = await this.prismaService
12✔
359
      .txClient()
12✔
360
      .$queryRawUnsafe<
12✔
361
        { count: number }[]
362
      >(builder.select(this.knex.raw('COUNT(*) as count')).toQuery());
12✔
363
    const total = Number(res[0].count);
12✔
364

365
    // Get unique total - distinct users across space and base collaborators
12✔
366
    const uniqBuilder = this.knex.queryBuilder();
12✔
367
    await this.getSpaceCollaboratorBuilder(uniqBuilder, spaceId, { ...options, includeBase: true });
12✔
368
    const uniqRes = await this.prismaService
12✔
369
      .txClient()
12✔
370
      .$queryRawUnsafe<
12✔
371
        { count: number }[]
372
      >(uniqBuilder.select(this.knex.raw('COUNT(DISTINCT users.id) as count')).toQuery());
12✔
373
    const uniqTotal = Number(uniqRes[0].count);
12✔
374

375
    return {
12✔
376
      total,
12✔
377
      uniqTotal,
12✔
378
    };
12✔
379
  }
12✔
380

381
  // eslint-disable-next-line sonarjs/no-identical-functions
133✔
382
  protected async getListBySpaceBuilder(
133✔
383
    builder: Knex.QueryBuilder,
12✔
384
    options?: {
12✔
385
      includeSystem?: boolean;
386
      includeBase?: boolean;
387
      skip?: number;
388
      take?: number;
389
      search?: string;
390
      type?: PrincipalType;
391
      orderBy?: 'desc' | 'asc';
392
    }
12✔
393
  ) {
12✔
394
    const { skip = 0, take = 50 } = options ?? {};
12✔
395
    builder.offset(skip);
12✔
396
    builder.limit(take);
12✔
397
    builder.select({
12✔
398
      resource_id: 'collaborator.resource_id',
12✔
399
      role_name: 'collaborator.role_name',
12✔
400
      created_time: 'collaborator.created_time',
12✔
401
      resource_type: 'collaborator.resource_type',
12✔
402
      user_id: 'users.id',
12✔
403
      user_name: 'users.name',
12✔
404
      user_email: 'users.email',
12✔
405
      user_avatar: 'users.avatar',
12✔
406
      user_is_system: 'users.is_system',
12✔
407
    });
12✔
408
    builder.orderBy('collaborator.created_time', options?.orderBy ?? 'desc');
12✔
409
  }
12✔
410

411
  async getListBySpace(
133✔
412
    spaceId: string,
12✔
413
    options?: {
12✔
414
      includeSystem?: boolean;
415
      includeBase?: boolean;
416
      skip?: number;
417
      take?: number;
418
      search?: string;
419
      type?: PrincipalType;
420
      orderBy?: 'desc' | 'asc';
421
    }
12✔
422
  ): Promise<CollaboratorItem[]> {
12✔
423
    const isCommunityEdition =
12✔
424
      process.env.NEXT_BUILD_ENV_EDITION?.toUpperCase() !== 'EE' &&
12✔
425
      process.env.NEXT_BUILD_ENV_EDITION?.toUpperCase() !== 'CLOUD';
12✔
426

427
    const builder = this.knex.queryBuilder();
12✔
428
    builder.whereNotNull('users.id');
12✔
429
    const { baseMap } = await this.getSpaceCollaboratorBuilder(builder, spaceId, options);
12✔
430
    await this.getListBySpaceBuilder(builder, options);
12✔
431
    const collaborators = await this.prismaService.txClient().$queryRawUnsafe<
12✔
432
      {
433
        resource_id: string;
434
        role_name: string;
435
        created_time: Date;
436
        resource_type: string;
437
        user_id: string;
438
        user_name: string;
439
        user_email: string;
440
        user_avatar: string;
441
        user_is_system: boolean | null;
442
      }[]
443
    >(builder.toQuery());
12✔
444

445
    // Get billable users if not community edition and includeBase is true
12✔
446
    let billableUserIds = new Set<string>();
12✔
447
    if (!isCommunityEdition && options?.includeBase) {
12✔
448
      const billableRoles = ['owner', 'creator', 'editor'];
×
449
      const billableBuilder = this.knex.queryBuilder();
×
450
      await this.getSpaceCollaboratorBuilder(billableBuilder, spaceId, {
×
451
        ...options,
×
452
        includeBase: true,
×
453
      });
×
454
      billableBuilder.whereIn('collaborator.role_name', billableRoles);
×
455
      billableBuilder.select({ user_id: 'users.id' });
×
456

457
      const billableUsers = await this.prismaService
×
458
        .txClient()
×
459
        .$queryRawUnsafe<{ user_id: string }[]>(billableBuilder.toQuery());
×
460

461
      billableUserIds = new Set(billableUsers.map((u) => u.user_id));
×
462
    }
×
463

464
    return collaborators.map((collaborator) => {
12✔
465
      const billableRoles = ['owner', 'creator', 'editor'];
24✔
466

467
      return {
24✔
468
        type: PrincipalType.User,
24✔
469
        resourceType: collaborator.resource_type as CollaboratorType,
24✔
470
        userId: collaborator.user_id,
24✔
471
        userName: collaborator.user_name,
24✔
472
        email: collaborator.user_email,
24✔
473
        avatar: collaborator.user_avatar ? getPublicFullStorageUrl(collaborator.user_avatar) : null,
24✔
474
        role: collaborator.role_name as IRole,
24✔
475
        createdTime: collaborator.created_time.toISOString(),
24✔
476
        base: baseMap[collaborator.resource_id],
24✔
477
        billable:
24✔
478
          !isCommunityEdition &&
24✔
479
          (billableRoles.includes(collaborator.role_name) ||
×
480
            billableUserIds.has(collaborator.user_id)),
×
481
      };
24✔
482
    });
24✔
483
  }
12✔
484

485
  private async getOperatorCollaborators({
133✔
486
    targetPrincipalId,
20✔
487
    currentPrincipalId,
20✔
488
    resourceId,
20✔
489
    resourceType,
20✔
490
  }: {
491
    resourceId: string;
492
    resourceType: CollaboratorType;
493
    targetPrincipalId: string;
494
    currentPrincipalId: string;
495
  }) {
20✔
496
    const currentUserWhere: {
20✔
497
      principalId: string;
498
      resourceId: string | Record<string, string[]>;
499
    } = {
20✔
500
      principalId: currentPrincipalId,
20✔
501
      resourceId,
20✔
502
    };
20✔
503
    const targetUserWhere: {
20✔
504
      principalId: string;
505
      resourceId: string | Record<string, string[]>;
506
    } = {
20✔
507
      principalId: targetPrincipalId,
20✔
508
      resourceId,
20✔
509
    };
20✔
510

511
    // for space user delete base collaborator
20✔
512
    if (resourceType === CollaboratorType.Base) {
20✔
513
      const spaceId = await this.prismaService
13✔
514
        .txClient()
13✔
515
        .base.findUniqueOrThrow({
13✔
516
          where: { id: resourceId, deletedTime: null },
13✔
517
          select: { spaceId: true },
13✔
518
        })
13✔
519
        .then((base) => base.spaceId);
13✔
520
      currentUserWhere.resourceId = { in: [resourceId, spaceId] };
13✔
521
    }
13✔
522
    const colls = await this.prismaService.txClient().collaborator.findMany({
20✔
523
      where: {
20✔
524
        OR: [currentUserWhere, targetUserWhere],
20✔
525
      },
20✔
526
    });
20✔
527

528
    const currentColl = colls.find((coll) => coll.principalId === currentPrincipalId);
20✔
529
    const targetColl = colls.find((coll) => coll.principalId === targetPrincipalId);
20✔
530
    if (!currentColl || !targetColl) {
20✔
531
      throw new BadRequestException('User not found in collaborator');
×
532
    }
×
533
    return { currentColl, targetColl };
20✔
534
  }
20✔
535

536
  async isUniqueOwnerUser(spaceId: string, userId: string) {
133✔
537
    const builder = this.knex('collaborator')
8✔
538
      .leftJoin('users', 'collaborator.principal_id', 'users.id')
8✔
539
      .where('collaborator.resource_id', spaceId)
8✔
540
      .where('collaborator.resource_type', CollaboratorType.Space)
8✔
541
      .where('collaborator.role_name', Role.Owner)
8✔
542
      .where('users.is_system', null)
8✔
543
      .where('users.deleted_time', null)
8✔
544
      .where('users.deactivated_time', null)
8✔
545
      .select('collaborator.principal_id');
8✔
546
    const collaborators = await this.prismaService.txClient().$queryRawUnsafe<
8✔
547
      {
548
        principal_id: string;
549
      }[]
550
    >(builder.toQuery());
8✔
551
    return collaborators.length === 1 && collaborators[0].principal_id === userId;
8✔
552
  }
8✔
553

554
  async deleteCollaborator({
133✔
555
    resourceId,
9✔
556
    resourceType,
9✔
557
    principalId,
9✔
558
    principalType,
9✔
559
  }: {
560
    principalId: string;
561
    principalType: PrincipalType;
562
    resourceId: string;
563
    resourceType: CollaboratorType;
564
  }) {
9✔
565
    const currentUserId = this.cls.get('user.id');
9✔
566
    const { currentColl, targetColl } = await this.getOperatorCollaborators({
9✔
567
      currentPrincipalId: currentUserId,
9✔
568
      targetPrincipalId: principalId,
9✔
569
      resourceId,
9✔
570
      resourceType,
9✔
571
    });
9✔
572

573
    // validate user can operator target user
9✔
574
    if (
9✔
575
      currentUserId !== principalId &&
9✔
576
      currentColl.roleName !== Role.Owner &&
9✔
577
      !canManageRole(currentColl.roleName as IRole, targetColl.roleName)
4✔
578
    ) {
9✔
579
      throw new ForbiddenException(
2✔
580
        `You do not have permission to delete this collaborator: ${principalId}`
2✔
581
      );
582
    }
2✔
583
    const result = await this.prismaService.txClient().collaborator.delete({
7✔
584
      where: {
7✔
585
        // eslint-disable-next-line @typescript-eslint/naming-convention
7✔
586
        resourceType_resourceId_principalId_principalType: {
7✔
587
          resourceId: resourceId,
7✔
588
          resourceType: resourceType,
7✔
589
          principalId,
7✔
590
          principalType,
7✔
591
        },
7✔
592
      },
7✔
593
    });
7✔
594
    let spaceId: string = resourceId;
7✔
595
    if (resourceType === CollaboratorType.Base) {
8✔
596
      const space = await this.prismaService
4✔
597
        .txClient()
4✔
598
        .base.findUniqueOrThrow({ where: { id: resourceId }, select: { spaceId: true } });
4✔
599
      spaceId = space.spaceId;
4✔
600
    }
4✔
601
    this.eventEmitterService.emitAsync(
7✔
602
      Events.COLLABORATOR_DELETE,
7✔
603
      new CollaboratorDeleteEvent(spaceId)
7✔
604
    );
605
    return result;
7✔
606
  }
7✔
607

608
  async updateCollaborator({
133✔
609
    role,
11✔
610
    principalId,
11✔
611
    principalType,
11✔
612
    resourceId,
11✔
613
    resourceType,
11✔
614
  }: {
615
    role: IRole;
616
    principalId: string;
617
    principalType: PrincipalType;
618
    resourceId: string;
619
    resourceType: CollaboratorType;
620
  }) {
11✔
621
    const currentUserId = this.cls.get('user.id');
11✔
622
    const { currentColl, targetColl } = await this.getOperatorCollaborators({
11✔
623
      currentPrincipalId: currentUserId,
11✔
624
      targetPrincipalId: principalId,
11✔
625
      resourceId,
11✔
626
      resourceType,
11✔
627
    });
11✔
628

629
    // validate user can operator target user
11✔
630
    if (
11✔
631
      currentUserId !== principalId &&
11✔
632
      currentColl.roleName !== targetColl.roleName &&
11✔
633
      !canManageRole(currentColl.roleName as IRole, targetColl.roleName)
6✔
634
    ) {
11✔
635
      throw new ForbiddenException(
1✔
636
        `You do not have permission to operator this collaborator: ${principalId}`
1✔
637
      );
638
    }
1✔
639

640
    // validate user can operator target role
10✔
641
    if (role !== currentColl.roleName && !canManageRole(currentColl.roleName as IRole, role)) {
11✔
642
      throw new ForbiddenException(`You do not have permission to operator this role: ${role}`);
2✔
643
    }
2✔
644

645
    return this.prismaService.txClient().collaborator.updateMany({
8✔
646
      where: {
8✔
647
        resourceId: resourceId,
8✔
648
        resourceType: resourceType,
8✔
649
        principalId: principalId,
8✔
650
        principalType: principalType,
8✔
651
      },
8✔
652
      data: {
8✔
653
        roleName: role,
8✔
654
        lastModifiedBy: currentUserId,
8✔
655
      },
8✔
656
    });
8✔
657
  }
8✔
658

659
  async getCurrentUserCollaboratorsBaseAndSpaceArray(searchRoles?: IRole[]) {
133✔
660
    const userId = this.cls.get('user.id');
10✔
661
    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);
10✔
662
    const collaborators = await this.prismaService.txClient().collaborator.findMany({
10✔
663
      where: {
10✔
664
        principalId: { in: [userId, ...(departmentIds || [])] },
10✔
665
        ...(searchRoles && searchRoles.length > 0 ? { roleName: { in: searchRoles } } : {}),
10✔
666
      },
10✔
667
      select: {
10✔
668
        roleName: true,
10✔
669
        resourceId: true,
10✔
670
        resourceType: true,
10✔
671
      },
10✔
672
    });
10✔
673
    const roleMap: Record<string, IRole> = {};
10✔
674
    const baseIds = new Set<string>();
10✔
675
    const spaceIds = new Set<string>();
10✔
676
    collaborators.forEach(({ resourceId, roleName, resourceType }) => {
10✔
677
      if (!roleMap[resourceId] || canManageRole(roleName as IRole, roleMap[resourceId])) {
244✔
678
        roleMap[resourceId] = roleName as IRole;
244✔
679
      }
244✔
680
      if (resourceType === CollaboratorType.Base) {
244✔
681
        baseIds.add(resourceId);
175✔
682
      } else {
244✔
683
        spaceIds.add(resourceId);
69✔
684
      }
69✔
685
    });
244✔
686
    return {
10✔
687
      baseIds: Array.from(baseIds),
10✔
688
      spaceIds: Array.from(spaceIds),
10✔
689
      roleMap: roleMap,
10✔
690
    };
10✔
691
  }
10✔
692

693
  async createBaseCollaborator({
133✔
694
    collaborators,
52✔
695
    baseId,
52✔
696
    role,
52✔
697
    createdBy,
52✔
698
  }: {
699
    collaborators: {
700
      principalId: string;
701
      principalType: PrincipalType;
702
    }[];
703
    baseId: string;
704
    role: IBaseRole;
705
    createdBy?: string;
706
  }) {
52✔
707
    const currentUserId = createdBy || this.cls.get('user.id');
52✔
708
    const base = await this.prismaService.txClient().base.findUniqueOrThrow({
52✔
709
      where: { id: baseId },
52✔
710
    });
52✔
711
    const exist = await this.prismaService.txClient().collaborator.count({
52✔
712
      where: {
52✔
713
        OR: collaborators.map((collaborator) => ({
52✔
714
          principalId: collaborator.principalId,
52✔
715
          principalType: collaborator.principalType,
52✔
716
        })),
52✔
717
        resourceId: { in: [baseId, base.spaceId] },
52✔
718
      },
52✔
719
    });
52✔
720
    // if has exist space collaborator
52✔
721
    if (exist) {
52✔
722
      throw new BadRequestException('has already existed in base');
1✔
723
    }
1✔
724

725
    const res = await this.prismaService.txClient().collaborator.createMany({
51✔
726
      data: collaborators.map((collaborator) => ({
51✔
727
        id: getRandomString(16),
51✔
728
        resourceId: baseId,
51✔
729
        resourceType: CollaboratorType.Base,
51✔
730
        roleName: role,
51✔
731
        principalId: collaborator.principalId,
51✔
732
        principalType: collaborator.principalType,
51✔
733
        createdBy: currentUserId!,
51✔
734
      })),
51✔
735
    });
51✔
736
    this.eventEmitterService.emitAsync(
51✔
737
      Events.COLLABORATOR_CREATE,
51✔
738
      new CollaboratorCreateEvent(base.spaceId)
51✔
739
    );
740
    return res;
51✔
741
  }
51✔
742

743
  async getSharedBase() {
133✔
744
    const userId = this.cls.get('user.id');
×
745
    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);
×
746
    const coll = await this.prismaService.txClient().collaborator.findMany({
×
747
      where: {
×
748
        principalId: { in: [userId, ...(departmentIds || [])] },
×
749
        resourceType: CollaboratorType.Base,
×
750
      },
×
751
      select: {
×
752
        resourceId: true,
×
753
        roleName: true,
×
754
      },
×
755
    });
×
756

757
    if (!coll.length) {
×
758
      return [];
×
759
    }
×
760

761
    const roleMap: Record<string, IRole> = {};
×
762
    const baseIds = coll.map((c) => {
×
763
      if (!roleMap[c.resourceId] || canManageRole(c.roleName as IRole, roleMap[c.resourceId])) {
×
764
        roleMap[c.resourceId] = c.roleName as IRole;
×
765
      }
×
766
      return c.resourceId;
×
767
    });
×
768
    const bases = await this.prismaService.txClient().base.findMany({
×
769
      where: {
×
770
        id: { in: baseIds },
×
771
        deletedTime: null,
×
772
      },
×
773
      include: {
×
774
        space: {
×
775
          select: {
×
776
            name: true,
×
777
          },
×
778
        },
×
779
      },
×
780
    });
×
781
    return bases.map((base) => ({
×
782
      id: base.id,
×
783
      name: base.name,
×
784
      role: roleMap[base.id],
×
785
      icon: base.icon,
×
786
      spaceId: base.spaceId,
×
787
      spaceName: base.space?.name,
×
788
      collaboratorType: CollaboratorType.Base,
×
789
    }));
×
790
  }
×
791

792
  protected async validateCollaboratorUser(userIds: string[]) {
133✔
793
    const users = await this.prismaService.txClient().user.findMany({
×
794
      where: {
×
795
        id: { in: userIds },
×
796
        deletedTime: null,
×
797
      },
×
798
      select: {
×
799
        id: true,
×
800
      },
×
801
    });
×
802
    const diffIds = difference(
×
803
      userIds,
×
804
      users.map((u) => u.id)
×
805
    );
806
    if (diffIds.length > 0) {
×
807
      throw new BadRequestException(`User not found: ${diffIds.join(', ')}`);
×
808
    }
×
809
  }
×
810

811
  async addSpaceCollaborators(spaceId: string, collaborator: AddSpaceCollaboratorRo) {
133✔
812
    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);
×
813
    await this.validateUserAddRole({
×
814
      departmentIds,
×
815
      userId: this.cls.get('user.id'),
×
816
      addRole: collaborator.role,
×
817
      resourceId: spaceId,
×
818
      resourceType: CollaboratorType.Space,
×
819
    });
×
820
    await this.validateCollaboratorUser(
×
821
      collaborator.collaborators
×
822
        .filter((c) => c.principalType === PrincipalType.User)
×
823
        .map((c) => c.principalId)
×
824
    );
825
    return this.createSpaceCollaborator({
×
826
      collaborators: collaborator.collaborators,
×
827
      spaceId,
×
828
      role: collaborator.role,
×
829
      createdBy: this.cls.get('user.id'),
×
830
    });
×
831
  }
×
832

833
  async addBaseCollaborators(baseId: string, collaborator: AddBaseCollaboratorRo) {
133✔
834
    const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);
×
835
    await this.validateUserAddRole({
×
836
      departmentIds,
×
837
      userId: this.cls.get('user.id'),
×
838
      addRole: collaborator.role,
×
839
      resourceId: baseId,
×
840
      resourceType: CollaboratorType.Base,
×
841
    });
×
842
    await this.validateCollaboratorUser(
×
843
      collaborator.collaborators
×
844
        .filter((c) => c.principalType === PrincipalType.User)
×
845
        .map((c) => c.principalId)
×
846
    );
847
    return this.createBaseCollaborator({
×
848
      collaborators: collaborator.collaborators,
×
849
      baseId,
×
850
      role: collaborator.role,
×
851
      createdBy: this.cls.get('user.id'),
×
852
    });
×
853
  }
×
854

855
  async validateUserAddRole({
133✔
856
    departmentIds,
99✔
857
    userId,
99✔
858
    addRole,
99✔
859
    resourceId,
99✔
860
    resourceType,
99✔
861
  }: {
862
    departmentIds?: string[];
863
    userId: string;
864
    addRole: IRole;
865
    resourceId: string;
866
    resourceType: CollaboratorType;
867
  }) {
99✔
868
    let spaceId = resourceType === CollaboratorType.Space ? resourceId : '';
99✔
869
    if (resourceType === CollaboratorType.Base) {
99✔
870
      const base = await this.prismaService
58✔
871
        .txClient()
58✔
872
        .base.findFirstOrThrow({
58✔
873
          where: {
58✔
874
            id: resourceId,
58✔
875
            deletedTime: null,
58✔
876
          },
58✔
877
        })
58✔
878
        .catch(() => {
58✔
879
          throw new BadRequestException('Base not found');
×
880
        });
×
881
      spaceId = base.spaceId;
58✔
882
    }
58✔
883
    const collaborators = await this.prismaService.txClient().collaborator.findMany({
99✔
884
      where: {
99✔
885
        principalId: departmentIds ? { in: [...departmentIds, userId] } : userId,
99✔
886
        resourceId: {
99✔
887
          in: [spaceId, resourceId],
99✔
888
        },
99✔
889
      },
99✔
890
    });
99✔
891
    if (collaborators.length === 0) {
99✔
892
      throw new BadRequestException('User not found in collaborator');
×
893
    }
×
894
    const userRole = getMaxLevelRole(collaborators);
99✔
895

896
    if (userRole === addRole) {
99✔
897
      return;
10✔
898
    }
10✔
899
    if (!canManageRole(userRole, addRole)) {
96✔
900
      throw new ForbiddenException(
4✔
901
        `You do not have permission to add this role collaborator: ${addRole}`
4✔
902
      );
903
    }
4✔
904
  }
99✔
905

906
  async getUserCollaboratorsTotal(baseId: string, options?: IListBaseCollaboratorUserRo) {
133✔
907
    return this.getTotalBase(baseId, options);
3✔
908
  }
3✔
909

910
  async getUserCollaborators(baseId: string, options?: IListBaseCollaboratorUserRo) {
133✔
911
    const { skip = 0, take = 50 } = options ?? {};
4✔
912
    const builder = this.knex.queryBuilder();
4✔
913
    await this.getBaseCollaboratorBuilder(builder, baseId, options);
4✔
914
    builder.whereNotNull('users.id');
4✔
915
    builder.orderBy('collaborator.created_time', options?.orderBy ?? 'desc');
4✔
916
    builder.offset(skip);
4✔
917
    builder.limit(take);
4✔
918
    builder.select({
4✔
919
      id: 'users.id',
4✔
920
      name: 'users.name',
4✔
921
      email: 'users.email',
4✔
922
      avatar: 'users.avatar',
4✔
923
    });
4✔
924
    const res = await this.prismaService
4✔
925
      .txClient()
4✔
926
      .$queryRawUnsafe<IItemBaseCollaboratorUser[]>(builder.toQuery());
4✔
927
    return res.map((item) => ({
4✔
928
      ...item,
8✔
929
      avatar: item.avatar ? getPublicFullStorageUrl(item.avatar) : null,
8✔
930
    }));
8✔
931
  }
4✔
932
}
133✔
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