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

teableio / teable / 8537011228

03 Apr 2024 10:15AM UTC coverage: 82.825% (+61.3%) from 21.535%
8537011228

Pull #514

github

web-flow
Merge fe52186b9 into 45ee7ebb3
Pull Request #514: refactor: user and link selector

4033 of 4226 branches covered (95.43%)

343 of 372 new or added lines in 8 files covered. (92.2%)

26958 of 32548 relevant lines covered (82.83%)

1213.2 hits per line

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

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

4✔
47
export interface IShareViewInfo {
4✔
48
  shareId: string;
4✔
49
  tableId: string;
4✔
50
  view: IViewVo;
4✔
51
}
4✔
52

4✔
53
export interface IJwtShareInfo {
4✔
54
  shareId: string;
4✔
55
  password: string;
4✔
56
}
4✔
57

4✔
58
@Injectable()
4✔
59
export class ShareService {
4✔
60
  constructor(
70✔
61
    private readonly prismaService: PrismaService,
70✔
62
    private readonly fieldService: FieldService,
70✔
63
    private readonly recordService: RecordService,
70✔
64
    private readonly aggregationService: AggregationService,
70✔
65
    private readonly recordOpenApiService: RecordOpenApiService,
70✔
66
    private readonly selectionService: SelectionService,
70✔
67
    private readonly collaboratorService: CollaboratorService,
70✔
68
    @InjectDbProvider() private readonly dbProvider: IDbProvider,
70✔
69
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex
70✔
70
  ) {}
70✔
71

70✔
72
  async getShareView(shareId: string): Promise<ShareViewGetVo> {
70✔
73
    const view = await this.prismaService.view.findFirst({
2✔
74
      where: { shareId, enableShare: true, deletedTime: null },
2✔
75
    });
2✔
76
    if (!view) {
2✔
77
      throw new BadRequestException('share view not found');
×
78
    }
×
79
    const shareMeta = view.shareMeta ? (JSON.parse(view.shareMeta) as IShareViewMeta) : undefined;
2✔
80
    const { tableId, id: viewId } = view;
2✔
81
    const fields = await this.fieldService.getFieldsByQuery(tableId, {
2✔
82
      viewId: view.id,
2✔
83
      filterHidden: !shareMeta?.includeHiddenField,
2✔
84
    });
2✔
85
    const { records } = await this.recordService.getRecords(tableId, {
2✔
86
      viewId,
2✔
87
      skip: 0,
2✔
88
      take: 50,
2✔
89
      fieldKeyType: FieldKeyType.Id,
2✔
90
      projection: fields.map((f) => f.id),
2✔
91
    });
2✔
92
    return {
2✔
93
      shareMeta,
2✔
94
      shareId,
2✔
95
      tableId,
2✔
96
      viewId,
2✔
97
      view: createViewVoByRaw(view),
2✔
98
      fields,
2✔
99
      records,
2✔
100
    };
2✔
101
  }
2✔
102

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

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

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

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

70✔
141
  async formSubmit(shareInfo: IShareViewInfo, shareViewFormSubmitRo: ShareViewFormSubmitRo) {
70✔
142
    const { tableId } = shareInfo;
2✔
143
    const { fields } = shareViewFormSubmitRo;
2✔
144
    const { records } = await this.prismaService.$tx(async () => {
2✔
145
      return await this.recordOpenApiService.createRecords(tableId, {
2✔
146
        records: [{ fields }],
2✔
147
        fieldKeyType: FieldKeyType.Id,
2✔
148
      });
2✔
149
    });
2✔
150
    if (records.length === 0) {
2✔
151
      throw new InternalServerErrorException('The number of successful submit records is 0');
×
152
    }
×
153
    return records[0];
2✔
154
  }
2✔
155

70✔
156
  async copy(shareInfo: IShareViewInfo, shareViewCopyRo: IRangesRo) {
70✔
157
    return this.selectionService.copy(shareInfo.tableId, {
×
158
      viewId: shareInfo.view.id,
×
159
      ...shareViewCopyRo,
×
160
    });
×
161
  }
×
162

70✔
163
  private async preCheckFieldHidden(view: IViewVo, fieldId: string) {
70✔
164
    // hidden check
10✔
165
    if (
10✔
166
      !view.shareMeta?.includeHiddenField &&
10✔
167
      (view.columnMeta[fieldId] as { hidden?: boolean })?.hidden
10✔
168
    ) {
10✔
NEW
169
      throw new ForbiddenException('field is hidden, not allowed');
×
NEW
170
    }
×
171
  }
10✔
172

70✔
173
  async getViewLinkRecords(shareInfo: IShareViewInfo, query: IShareViewLinkRecordsRo) {
70✔
174
    const { tableId, view } = shareInfo;
4✔
175
    const { fieldId } = query;
4✔
176

4✔
177
    await this.preCheckFieldHidden(view, fieldId);
4✔
178

4✔
179
    // link field check
4✔
180
    const field = await this.fieldService.getField(tableId, fieldId);
4✔
181
    if (field.type !== FieldType.Link) {
4✔
NEW
182
      throw new ForbiddenException('field type is not link field');
×
183
    }
×
184

4✔
185
    let recordsVo: IRecordsVo;
4✔
186
    if (view.type === ViewType.Form) {
4✔
187
      recordsVo = await this.getFormLinkRecords(field, query);
2✔
188
    } else {
2✔
189
      recordsVo = await this.getViewFilterLinkRecords(field, query);
2✔
190
    }
2✔
191
    return recordsVo.records.map(({ id, name }) => ({ id, title: name }));
4✔
192
  }
4✔
193

70✔
194
  async getFormLinkRecords(field: IFieldVo, query: IShareViewLinkRecordsRo) {
70✔
195
    const { lookupFieldId, foreignTableId } = field.options as ILinkFieldOptions;
2✔
196
    const { take, skip, search } = query;
2✔
197

2✔
198
    return this.recordService.getRecords(foreignTableId, {
2✔
199
      take,
2✔
200
      skip,
2✔
201
      search: search ? [search, lookupFieldId] : undefined,
2✔
202
      projection: [lookupFieldId],
2✔
203
      fieldKeyType: FieldKeyType.Id,
2✔
204
      filterLinkCellCandidate: field.id,
2✔
205
    });
2✔
206
  }
2✔
207

70✔
208
  async getViewFilterLinkRecords(field: IFieldVo, query: IShareViewLinkRecordsRo) {
70✔
209
    const { fieldId, skip, take, search } = query;
2✔
210

2✔
211
    const { foreignTableId, lookupFieldId } = field.options as ILinkFieldOptions;
2✔
212

2✔
213
    return this.recordService.getRecords(foreignTableId, {
2✔
214
      skip,
2✔
215
      take,
2✔
216
      search: search ? [search, lookupFieldId] : undefined,
2✔
217
      fieldKeyType: FieldKeyType.Id,
2✔
218
      projection: [lookupFieldId],
2✔
219
      filterLinkCellSelected: fieldId,
2✔
220
    });
2✔
221
  }
2✔
222

70✔
223
  async getViewGroupPoints(
70✔
224
    shareInfo: IShareViewInfo,
×
225
    query?: IShareViewGroupPointsRo
×
226
  ): Promise<IGroupPointsVo> {
×
227
    const viewId = shareInfo.view.id;
×
228
    const tableId = shareInfo.tableId;
×
229

×
230
    if (viewId == null) return null;
×
231

×
232
    return await this.aggregationService.getGroupPoints(tableId, { ...query, viewId });
×
233
  }
×
234

70✔
235
  async getViewCollaborators(shareInfo: IShareViewInfo, query: IShareViewCollaboratorsRo) {
70✔
236
    const { view, tableId } = shareInfo;
8✔
237
    const { fieldId } = query;
8✔
238

8✔
239
    // only form view can get all records
8✔
240
    if (view.type === ViewType.Form) {
8✔
241
      return this.getViewFormCollaborators(shareInfo);
2✔
242
    }
2✔
243

6✔
244
    if (!fieldId) {
8✔
NEW
245
      throw new BadRequestException('fieldId is required');
×
NEW
246
    }
✔
247

6✔
248
    await this.preCheckFieldHidden(view, fieldId);
6✔
249

6✔
250
    // user field check
6✔
251
    const field = await this.fieldService.getField(tableId, fieldId);
6✔
252
    // All user field, contains lastModifiedBy, createdBy
6✔
253
    if (field.type !== FieldType.User) {
8✔
NEW
254
      throw new ForbiddenException('field type is not user field');
×
NEW
255
    }
✔
256

6✔
257
    return this.getViewFilterCollaborators(shareInfo, field);
6✔
258
  }
6✔
259

70✔
260
  private async getViewFilterUserQuery(
70✔
261
    tableId: string,
6✔
262
    filter: IFilter | undefined,
6✔
263
    userField: IFieldVo,
6✔
264
    fieldMap: Record<string, IFieldInstance>
6✔
265
  ) {
6✔
266
    const dbTableName = await this.recordService.getDbTableName(tableId);
6✔
267
    const queryBuilder = this.knex(dbTableName);
6✔
268
    const { isMultipleCellValue, dbFieldName } = userField;
6✔
269

6✔
270
    this.dbProvider.shareFilterCollaboratorsQuery(queryBuilder, dbFieldName, isMultipleCellValue);
6✔
271
    queryBuilder.whereNotNull(dbFieldName);
6✔
272
    this.dbProvider.filterQuery(queryBuilder, fieldMap, filter).appendQueryBuilder();
6✔
273

6✔
274
    return this.knex('users')
6✔
275
      .select('id', 'email', 'name', 'avatar')
6✔
276
      .from(this.knex.raw(`(${queryBuilder.toQuery()}) AS coll`))
6✔
277
      .leftJoin('users', 'users.id', '=', 'coll.user_id')
6✔
278
      .toQuery();
6✔
279
  }
6✔
280

70✔
281
  async getViewFilterCollaborators(shareInfo: IShareViewInfo, field: IFieldVo) {
70✔
282
    const { tableId, view } = shareInfo;
6✔
283
    const fields = await this.fieldService.getFieldsByQuery(tableId, {
6✔
284
      viewId: view.id,
6✔
285
    });
6✔
286

6✔
287
    const nativeQuery = await this.getViewFilterUserQuery(
6✔
288
      tableId,
6✔
289
      view.filter,
6✔
290
      field,
6✔
291
      fields.reduce(
6✔
292
        (acc, field) => {
6✔
293
          acc[field.id] = createFieldInstanceByVo(field);
18✔
294
          return acc;
18✔
295
        },
18✔
296
        {} as Record<string, IFieldInstance>
6✔
297
      )
6✔
298
    );
6✔
299

6✔
300
    const users = await this.prismaService
6✔
301
      .txClient()
6✔
302
      // eslint-disable-next-line @typescript-eslint/naming-convention
6✔
303
      .$queryRawUnsafe<{ id: string; email: string; name: string; avatar: string | null }[]>(
6✔
304
        nativeQuery
6✔
305
      );
6✔
306

6✔
307
    return users.map(({ id, email, name, avatar }) => ({
6✔
308
      userId: id,
4✔
309
      email,
4✔
310
      userName: name,
4✔
311
      avatar: avatar && getFullStorageUrl(avatar),
4✔
312
    }));
4✔
313
  }
6✔
314

70✔
315
  async getViewFormCollaborators(shareInfo: IShareViewInfo) {
70✔
316
    const { tableId, view } = shareInfo;
2✔
317

2✔
318
    if (view.type !== ViewType.Form) {
2✔
NEW
319
      throw new ForbiddenException('view type is not allowed');
×
NEW
320
    }
×
321

2✔
322
    const fields = await this.fieldService.getFieldsByQuery(tableId, {
2✔
323
      viewId: view.id,
2✔
324
      filterHidden: !view.shareMeta?.includeHiddenField,
2✔
325
    });
2✔
326
    // If there is no user field, return an empty array
2✔
327
    if (!fields.some((field) => field.type === FieldType.User)) {
2✔
NEW
328
      return [];
×
NEW
329
    }
×
330
    const { baseId } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({
2✔
331
      select: { baseId: true },
2✔
332
      where: { id: tableId },
2✔
333
    });
2✔
334
    const list = await this.collaboratorService.getListByBase(baseId);
2✔
335
    return list.map((item) => pick(item, 'userId', 'email', 'userName', 'avatar'));
2✔
336
  }
2✔
337
}
70✔
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