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

teableio / teable / 11007026192

24 Sep 2024 04:31AM UTC coverage: 84.941% (-0.01%) from 84.954%
11007026192

Pull #935

github

web-flow
Merge 73b34825b into 10c0ccaac
Pull Request #935: feat: cross base link

5652 of 5956 branches covered (94.9%)

93 of 115 new or added lines in 7 files covered. (80.87%)

71 existing lines in 2 files now uncovered.

36974 of 43529 relevant lines covered (84.94%)

1164.31 hits per line

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

83.07
/apps/nestjs-backend/src/features/share/share.service.ts
1
/* eslint-disable sonarjs/no-duplicate-string */
4✔
2
import {
4✔
3
  BadRequestException,
4✔
4
  ForbiddenException,
4✔
5
  Injectable,
4✔
6
  InternalServerErrorException,
4✔
7
} from '@nestjs/common';
4✔
8
import type { IFilter, IFieldVo, IViewVo, ILinkFieldOptions, StatisticsFunc } from '@teable/core';
4✔
9
import { FieldKeyType, FieldType, ViewType } from '@teable/core';
4✔
10
import { PrismaService } from '@teable/db-main-prisma';
4✔
11
import {
4✔
12
  type ShareViewFormSubmitRo,
4✔
13
  type ShareViewGetVo,
4✔
14
  type IShareViewRowCountRo,
4✔
15
  type IShareViewAggregationsRo,
4✔
16
  type IRangesRo,
4✔
17
  type IShareViewGroupPointsRo,
4✔
18
  type IAggregationVo,
4✔
19
  type IGroupPointsVo,
4✔
20
  type IRowCountVo,
4✔
21
  type IShareViewLinkRecordsRo,
4✔
22
  type IRecordsVo,
4✔
23
  type IShareViewCollaboratorsRo,
4✔
24
  UploadType,
4✔
25
} from '@teable/openapi';
4✔
26
import { Knex } from 'knex';
4✔
27
import { isEmpty, pick } from 'lodash';
4✔
28
import { InjectModel } from 'nest-knexjs';
4✔
29
import { ClsService } from 'nestjs-cls';
4✔
30
import { InjectDbProvider } from '../../db-provider/db.provider';
4✔
31
import { IDbProvider } from '../../db-provider/db.provider.interface';
4✔
32
import type { IClsStore } from '../../types/cls';
4✔
33
import { isNotHiddenField } from '../../utils/is-not-hidden-field';
4✔
34
import { AggregationService } from '../aggregation/aggregation.service';
4✔
35
import StorageAdapter from '../attachments/plugins/adapter';
4✔
36
import { getFullStorageUrl } from '../attachments/plugins/utils';
4✔
37
import { CollaboratorService } from '../collaborator/collaborator.service';
4✔
38
import { FieldService } from '../field/field.service';
4✔
39
import type { IFieldInstance } from '../field/model/factory';
4✔
40
import { createFieldInstanceByVo } from '../field/model/factory';
4✔
41
import { RecordOpenApiService } from '../record/open-api/record-open-api.service';
4✔
42
import { RecordService } from '../record/record.service';
4✔
43
import { SelectionService } from '../selection/selection.service';
4✔
44
import type { IShareViewInfo } from './share-auth.service';
4✔
45

4✔
46
export interface IJwtShareInfo {
4✔
47
  shareId: string;
4✔
48
  password: string;
4✔
49
}
4✔
50

4✔
51
@Injectable()
4✔
52
export class ShareService {
4✔
53
  constructor(
93✔
54
    private readonly prismaService: PrismaService,
93✔
55
    private readonly fieldService: FieldService,
93✔
56
    private readonly recordService: RecordService,
93✔
57
    private readonly aggregationService: AggregationService,
93✔
58
    private readonly recordOpenApiService: RecordOpenApiService,
93✔
59
    private readonly selectionService: SelectionService,
93✔
60
    private readonly collaboratorService: CollaboratorService,
93✔
61
    private readonly cls: ClsService<IClsStore>,
93✔
62
    @InjectDbProvider() private readonly dbProvider: IDbProvider,
93✔
63
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex
93✔
64
  ) {}
93✔
65

93✔
66
  async getShareView(shareInfo: IShareViewInfo): Promise<ShareViewGetVo> {
93✔
67
    const { shareId, tableId, view } = shareInfo;
8✔
68

8✔
69
    const { id: viewId, group, shareMeta } = view ?? {};
8✔
70
    const fields = await this.fieldService.getFieldsByQuery(tableId, {
8✔
71
      viewId,
8✔
72
      filterHidden: !shareMeta?.includeHiddenField,
8✔
73
    });
8✔
74

8✔
75
    let records: IRecordsVo['records'] = [];
8✔
76
    let extra: IRecordsVo['extra'];
8✔
77
    if (view?.type !== ViewType.Form) {
8✔
78
      const recordsData = await this.recordService.getRecords(tableId, {
6✔
79
        viewId,
6✔
80
        skip: 0,
6✔
81
        take: 50,
6✔
82
        groupBy: group,
6✔
83
        fieldKeyType: FieldKeyType.Id,
6✔
84
        projection: fields.map((f) => f.id),
6✔
85
      });
6✔
86
      records = recordsData.records;
6✔
87
      extra = recordsData.extra;
6✔
88
    }
6✔
89

8✔
90
    return {
8✔
91
      shareMeta,
8✔
92
      shareId,
8✔
93
      tableId,
8✔
94
      viewId,
8✔
95
      view,
8✔
96
      fields,
8✔
97
      records,
8✔
98
      extra,
8✔
99
    };
8✔
100
  }
8✔
101

93✔
102
  async getViewAggregations(
93✔
UNCOV
103
    shareInfo: IShareViewInfo,
×
UNCOV
104
    query: IShareViewAggregationsRo = {}
×
105
  ): Promise<IAggregationVo> {
×
NEW
106
    const viewId = shareInfo.view?.id;
×
107
    const tableId = shareInfo.tableId;
×
108
    const filter = query?.filter ?? null;
×
109
    const fieldStats: Array<{ fieldId: string; statisticFunc: StatisticsFunc }> = [];
×
110
    if (query?.field) {
×
111
      Object.entries(query.field).forEach(([key, value]) => {
×
112
        const stats = value.map((fieldId) => ({
×
113
          fieldId,
×
114
          statisticFunc: key as StatisticsFunc,
×
115
        }));
×
116
        fieldStats.push(...stats);
×
117
      });
×
118
    }
×
119
    const result = await this.aggregationService.performAggregation({
×
120
      tableId,
×
121
      withView: { viewId, customFilter: filter, customFieldStats: fieldStats },
×
122
    });
×
123

×
124
    return { aggregations: result?.aggregations };
×
125
  }
×
126

93✔
127
  async getViewRowCount(
93✔
UNCOV
128
    shareInfo: IShareViewInfo,
×
UNCOV
129
    query?: IShareViewRowCountRo
×
130
  ): Promise<IRowCountVo> {
×
NEW
131
    const viewId = shareInfo.view?.id;
×
132
    const tableId = shareInfo.tableId;
×
133
    const result = await this.aggregationService.performRowCount(tableId, { viewId, ...query });
×
134

×
135
    return {
×
136
      rowCount: result.rowCount,
×
137
    };
×
138
  }
×
139

93✔
140
  async formSubmit(shareInfo: IShareViewInfo, shareViewFormSubmitRo: ShareViewFormSubmitRo) {
93✔
141
    const { tableId, view } = shareInfo;
6✔
142
    const { fields } = shareViewFormSubmitRo;
6✔
143
    if (!view) {
6✔
NEW
UNCOV
144
      throw new ForbiddenException('view is required');
×
NEW
145
    }
×
146

6✔
147
    if (view.type !== ViewType.Form) {
6✔
148
      throw new ForbiddenException('view type is not form');
2✔
149
    }
2✔
150

4✔
151
    const viewId = view.id;
4✔
152

4✔
153
    // check field hidden
4✔
154
    const visibleFields = await this.fieldService.getFieldsByQuery(tableId, {
4✔
155
      viewId,
4✔
156
      filterHidden: !view.shareMeta?.includeHiddenField,
6✔
157
    });
6✔
158
    const visibleFieldIds = visibleFields.map(({ id }) => id);
4✔
159
    const visibleFieldIdSet = new Set(visibleFieldIds);
4✔
160

4✔
161
    if (
4✔
162
      (!visibleFields.length && !isEmpty(fields)) ||
4✔
163
      Object.keys(fields).some((fieldId) => !visibleFieldIdSet.has(fieldId))
2✔
164
    ) {
6✔
165
      throw new ForbiddenException('The form contains hidden fields, submission not allowed.');
2✔
166
    }
2✔
167

2✔
168
    const { records } = await this.prismaService.$tx(async () => {
2✔
169
      this.cls.set('entry', { type: 'form', id: viewId });
2✔
170
      return await this.recordOpenApiService.createRecords(tableId, {
2✔
171
        records: [{ fields }],
2✔
172
        fieldKeyType: FieldKeyType.Id,
2✔
173
      });
2✔
174
    });
2✔
175
    if (records.length === 0) {
6✔
UNCOV
176
      throw new InternalServerErrorException('The number of successful submit records is 0');
×
UNCOV
177
    }
✔
178
    return records[0];
2✔
179
  }
2✔
180

93✔
181
  async copy(shareInfo: IShareViewInfo, shareViewCopyRo: IRangesRo) {
93✔
182
    if (shareInfo.view && !shareInfo.view.shareMeta?.allowCopy) {
4✔
183
      throw new ForbiddenException('not allowed to copy');
2✔
184
    }
2✔
185

2✔
186
    return this.selectionService.copy(shareInfo.tableId, {
2✔
187
      viewId: shareInfo.view?.id,
2✔
188
      ...shareViewCopyRo,
4✔
189
    });
4✔
190
  }
4✔
191

93✔
192
  private async preCheckFieldHidden(view: IViewVo, fieldId: string) {
93✔
193
    // hidden check
10✔
194
    if (!view.shareMeta?.includeHiddenField && !isNotHiddenField(fieldId, view)) {
10✔
UNCOV
195
      throw new ForbiddenException('field is hidden, not allowed');
×
UNCOV
196
    }
×
197
  }
10✔
198

93✔
199
  async getViewLinkRecords(shareInfo: IShareViewInfo, query: IShareViewLinkRecordsRo) {
93✔
200
    const { tableId, view } = shareInfo;
4✔
201
    const { fieldId } = query;
4✔
202
    if (!view) {
4✔
NEW
UNCOV
203
      throw new ForbiddenException('view is required');
×
NEW
204
    }
×
205

4✔
206
    await this.preCheckFieldHidden(view as IViewVo, fieldId);
4✔
207

4✔
208
    // link field check
4✔
209
    const field = await this.fieldService.getField(tableId, fieldId);
4✔
210
    if (field.type !== FieldType.Link) {
4✔
UNCOV
211
      throw new ForbiddenException('field type is not link field');
×
UNCOV
212
    }
×
213

4✔
214
    let recordsVo: IRecordsVo;
4✔
215
    if (view.type === ViewType.Form) {
4✔
216
      recordsVo = await this.getFormLinkRecords(field, query);
2✔
217
    } else {
2✔
218
      recordsVo = await this.getViewFilterLinkRecords(field, query);
2✔
219
    }
2✔
220
    return recordsVo.records.map(({ id, name }) => ({ id, title: name }));
4✔
221
  }
4✔
222

93✔
223
  async getFormLinkRecords(field: IFieldVo, query: IShareViewLinkRecordsRo) {
93✔
224
    const { lookupFieldId, foreignTableId } = field.options as ILinkFieldOptions;
2✔
225
    const { take, skip, search } = query;
2✔
226

2✔
227
    return this.recordService.getRecords(foreignTableId, {
2✔
228
      take,
2✔
229
      skip,
2✔
230
      search: search ? [search, lookupFieldId] : undefined,
2✔
231
      projection: [lookupFieldId],
2✔
232
      fieldKeyType: FieldKeyType.Id,
2✔
233
      filterLinkCellCandidate: field.id,
2✔
234
    });
2✔
235
  }
2✔
236

93✔
237
  async getViewFilterLinkRecords(field: IFieldVo, query: IShareViewLinkRecordsRo) {
93✔
238
    const { fieldId, skip, take, search } = query;
2✔
239

2✔
240
    const { foreignTableId, lookupFieldId } = field.options as ILinkFieldOptions;
2✔
241

2✔
242
    return this.recordService.getRecords(foreignTableId, {
2✔
243
      skip,
2✔
244
      take,
2✔
245
      search: search ? [search, lookupFieldId] : undefined,
2✔
246
      fieldKeyType: FieldKeyType.Id,
2✔
247
      projection: [lookupFieldId],
2✔
248
      filterLinkCellSelected: fieldId,
2✔
249
    });
2✔
250
  }
2✔
251

93✔
252
  async getViewGroupPoints(
93✔
UNCOV
253
    shareInfo: IShareViewInfo,
×
UNCOV
254
    query?: IShareViewGroupPointsRo
×
255
  ): Promise<IGroupPointsVo> {
×
NEW
256
    const viewId = shareInfo.view?.id;
×
257
    const tableId = shareInfo.tableId;
×
258

×
259
    if (viewId == null) return null;
×
260

×
261
    return await this.aggregationService.getGroupPoints(tableId, { ...query, viewId });
×
262
  }
×
263

93✔
264
  async getViewCollaborators(shareInfo: IShareViewInfo, query: IShareViewCollaboratorsRo) {
93✔
265
    const { view, tableId } = shareInfo;
10✔
266
    const { fieldId } = query;
10✔
267

10✔
268
    if (!view) {
10✔
NEW
UNCOV
269
      return this.getViewAllCollaborators(shareInfo);
×
NEW
270
    }
×
271

10✔
272
    // only form and kanban view can get all records
10✔
273
    if ([ViewType.Form, ViewType.Kanban].includes(view.type)) {
10✔
274
      return this.getViewAllCollaborators(shareInfo);
4✔
275
    }
4✔
276

6✔
277
    if (!fieldId) {
10✔
UNCOV
278
      throw new BadRequestException('fieldId is required');
×
UNCOV
279
    }
✔
280

6✔
281
    await this.preCheckFieldHidden(view as IViewVo, fieldId);
6✔
282

6✔
283
    // user field check
6✔
284
    const field = await this.fieldService.getField(tableId, fieldId);
6✔
285
    // All user field, contains lastModifiedBy, createdBy
6✔
286
    if (![FieldType.User, FieldType.LastModifiedBy, FieldType.CreatedBy].includes(field.type)) {
10✔
UNCOV
287
      throw new ForbiddenException('field type is not user-related field');
×
UNCOV
288
    }
✔
289

6✔
290
    return this.getViewFilterCollaborators(shareInfo, field);
6✔
291
  }
6✔
292

93✔
293
  private async getViewFilterUserQuery(
93✔
294
    tableId: string,
6✔
295
    filter: IFilter | undefined,
6✔
296
    userField: IFieldVo,
6✔
297
    fieldMap: Record<string, IFieldInstance>
6✔
298
  ) {
6✔
299
    const dbTableName = await this.recordService.getDbTableName(tableId);
6✔
300
    const queryBuilder = this.knex(dbTableName);
6✔
301
    const { isMultipleCellValue, dbFieldName } = userField;
6✔
302

6✔
303
    this.dbProvider.shareFilterCollaboratorsQuery(queryBuilder, dbFieldName, isMultipleCellValue);
6✔
304
    queryBuilder.whereNotNull(dbFieldName);
6✔
305
    this.dbProvider.filterQuery(queryBuilder, fieldMap, filter).appendQueryBuilder();
6✔
306

6✔
307
    return this.knex('users')
6✔
308
      .select('id', 'email', 'name', 'avatar')
6✔
309
      .from(this.knex.raw(`(${queryBuilder.toQuery()}) AS coll`))
6✔
310
      .leftJoin('users', 'users.id', '=', 'coll.user_id')
6✔
311
      .toQuery();
6✔
312
  }
6✔
313

93✔
314
  async getViewFilterCollaborators(shareInfo: IShareViewInfo, field: IFieldVo) {
93✔
315
    const { tableId, view } = shareInfo;
6✔
316
    if (!view) {
6✔
NEW
UNCOV
317
      throw new ForbiddenException('view is required');
×
NEW
318
    }
×
319

6✔
320
    const fields = await this.fieldService.getFieldsByQuery(tableId, {
6✔
321
      viewId: view.id,
6✔
322
    });
6✔
323

6✔
324
    const nativeQuery = await this.getViewFilterUserQuery(
6✔
325
      tableId,
6✔
326
      view.filter,
6✔
327
      field,
6✔
328
      fields.reduce(
6✔
329
        (acc, field) => {
6✔
330
          acc[field.id] = createFieldInstanceByVo(field);
18✔
331
          return acc;
18✔
332
        },
18✔
333
        {} as Record<string, IFieldInstance>
6✔
334
      )
6✔
335
    );
6✔
336

6✔
337
    const users = await this.prismaService
6✔
338
      .txClient()
6✔
339
      // eslint-disable-next-line @typescript-eslint/naming-convention
6✔
340
      .$queryRawUnsafe<{ id: string; email: string; name: string; avatar: string | null }[]>(
6✔
341
        nativeQuery
6✔
342
      );
6✔
343

6✔
344
    return users.map(({ id, email, name, avatar }) => ({
6✔
345
      userId: id,
4✔
346
      email,
4✔
347
      userName: name,
4✔
348
      avatar: avatar && getFullStorageUrl(StorageAdapter.getBucket(UploadType.Avatar), avatar),
4✔
349
    }));
4✔
350
  }
6✔
351

93✔
352
  async getViewAllCollaborators(shareInfo: IShareViewInfo) {
93✔
353
    const { tableId, view } = shareInfo;
4✔
354

4✔
355
    if (view && ![ViewType.Form, ViewType.Kanban].includes(view.type)) {
4✔
UNCOV
356
      throw new ForbiddenException('view type is not allowed');
×
357
    }
×
358

4✔
359
    const fields = await this.fieldService.getFieldsByQuery(tableId, {
4✔
360
      viewId: view?.id,
4✔
361
      filterHidden: !view?.shareMeta?.includeHiddenField,
4✔
362
    });
4✔
363
    // If there is no user field, return an empty array
4✔
364
    if (
4✔
365
      !fields.some((field) =>
4✔
366
        [FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(field.type)
2✔
367
      )
4✔
368
    ) {
4✔
369
      return [];
2✔
370
    }
2✔
371
    const { baseId } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({
2✔
372
      select: { baseId: true },
2✔
373
      where: { id: tableId },
2✔
374
    });
2✔
375
    const list = await this.collaboratorService.getListByBase(baseId);
2✔
376
    return list.map((item) => pick(item, 'userId', 'email', 'userName', 'avatar'));
2✔
377
  }
2✔
378
}
93✔
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