teableio/teable

View on GitHub
apps/nestjs-backend/src/features/share/share.service.ts

Summary

Maintainability
A
0 mins
Test Coverage
/* eslint-disable sonarjs/no-duplicate-string */
import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  InternalServerErrorException,
  NotFoundException,
} from '@nestjs/common';
import type { IFilter, IFieldVo, IViewVo, ILinkFieldOptions, StatisticsFunc } from '@teable/core';
import { FieldKeyType, FieldType, ViewType } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import {
  type ShareViewFormSubmitRo,
  type ShareViewGetVo,
  type IShareViewRowCountRo,
  type IShareViewAggregationsRo,
  type IRangesRo,
  type IShareViewGroupPointsRo,
  type IAggregationVo,
  type IGroupPointsVo,
  type IRowCountVo,
  type IShareViewLinkRecordsRo,
  type IRecordsVo,
  type IShareViewCollaboratorsRo,
  UploadType,
  ShareViewLinkRecordsType,
  PluginPosition,
} from '@teable/openapi';
import { Knex } from 'knex';
import { isEmpty, pick } from 'lodash';
import { InjectModel } from 'nest-knexjs';
import { ClsService } from 'nestjs-cls';
import { InjectDbProvider } from '../../db-provider/db.provider';
import { IDbProvider } from '../../db-provider/db.provider.interface';
import type { IClsStore } from '../../types/cls';
import { isNotHiddenField } from '../../utils/is-not-hidden-field';
import { AggregationService } from '../aggregation/aggregation.service';
import StorageAdapter from '../attachments/plugins/adapter';
import { getFullStorageUrl } from '../attachments/plugins/utils';
import { CollaboratorService } from '../collaborator/collaborator.service';
import { FieldService } from '../field/field.service';
import type { IFieldInstance } from '../field/model/factory';
import { createFieldInstanceByVo } from '../field/model/factory';
import { RecordOpenApiService } from '../record/open-api/record-open-api.service';
import { RecordService } from '../record/record.service';
import { SelectionService } from '../selection/selection.service';
import { ViewService } from '../view/view.service';
import type { IShareViewInfo } from './share-auth.service';

export interface IJwtShareInfo {
  shareId: string;
  password: string;
}

@Injectable()
export class ShareService {
  constructor(
    private readonly prismaService: PrismaService,
    private readonly viewService: ViewService,
    private readonly fieldService: FieldService,
    private readonly recordService: RecordService,
    private readonly aggregationService: AggregationService,
    private readonly recordOpenApiService: RecordOpenApiService,
    private readonly selectionService: SelectionService,
    private readonly collaboratorService: CollaboratorService,
    private readonly cls: ClsService<IClsStore>,
    @InjectDbProvider() private readonly dbProvider: IDbProvider,
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex
  ) {}

  async getShareView(shareInfo: IShareViewInfo): Promise<ShareViewGetVo> {
    const { shareId, tableId, view, linkOptions, shareMeta } = shareInfo;
    const { id, group } = view ?? {};
    const { filterByViewId, filter, visibleFieldIds } = linkOptions ?? {};
    const viewId = filterByViewId ?? id;

    const fields = await this.fieldService.getFieldsByQuery(tableId, {
      viewId,
      filterHidden: Boolean(filterByViewId) || !shareMeta?.includeHiddenField,
    });
    const filteredFields = visibleFieldIds?.length
      ? fields.filter((f) => visibleFieldIds?.includes(f.id) || f.isPrimary)
      : fields;

    let records: IRecordsVo['records'] = [];
    let extra: ShareViewGetVo['extra'];
    if (shareMeta?.includeRecords) {
      const recordsData = await this.recordService.getRecords(tableId, {
        viewId,
        skip: 0,
        take: 50,
        filter,
        groupBy: group,
        fieldKeyType: FieldKeyType.Id,
        projection: filteredFields.map((f) => f.id),
      });
      records = recordsData.records;
      extra = recordsData.extra;
    }

    if (view?.type === ViewType.Plugin) {
      const pluginInstall = await this.prismaService.pluginInstall.findFirst({
        where: { positionId: viewId, position: PluginPosition.View },
        select: {
          id: true,
          pluginId: true,
          name: true,
          storage: true,
          plugin: {
            select: {
              url: true,
            },
          },
        },
      });
      if (!pluginInstall) {
        throw new NotFoundException(`Plugin install not found`);
      }
      const plugin = {
        pluginId: pluginInstall.pluginId,
        pluginInstallId: pluginInstall.id,
        name: pluginInstall.name,
        storage: pluginInstall.storage ? JSON.parse(pluginInstall.storage) : undefined,
        url: pluginInstall.plugin.url || undefined,
      };
      if (extra) {
        extra.plugin = plugin;
      } else {
        extra = { plugin: plugin };
      }
    }

    return {
      shareMeta,
      shareId,
      tableId,
      viewId,
      view: view ? this.viewService.convertViewVoAttachmentUrl(view) : undefined,
      fields: filteredFields,
      records,
      extra,
    };
  }

  async getViewAggregations(
    shareInfo: IShareViewInfo,
    query: IShareViewAggregationsRo = {}
  ): Promise<IAggregationVo> {
    const { tableId, shareMeta } = shareInfo;
    if (!shareMeta?.includeRecords) {
      return { aggregations: [] };
    }
    const viewId = shareInfo.view?.id;
    const filter = query?.filter ?? null;
    const groupBy = query?.groupBy ?? null;
    const fieldStats: Array<{ fieldId: string; statisticFunc: StatisticsFunc }> = [];
    if (query?.field) {
      Object.entries(query.field).forEach(([key, value]) => {
        const stats = value.map((fieldId) => {
          // check field hidden
          if (shareInfo.view) {
            this.preCheckFieldHidden(shareInfo.view as IViewVo, key);
          }
          return {
            fieldId,
            statisticFunc: key as StatisticsFunc,
          };
        });
        fieldStats.push(...stats);
      });
    }
    const result = await this.aggregationService.performAggregation({
      tableId,
      withView: { viewId, customFilter: filter, customFieldStats: fieldStats, groupBy },
    });

    return { aggregations: result?.aggregations };
  }

  async getViewRowCount(
    shareInfo: IShareViewInfo,
    query?: IShareViewRowCountRo
  ): Promise<IRowCountVo> {
    const { view, linkOptions, shareMeta } = shareInfo;

    if (!shareMeta?.includeRecords) {
      return { rowCount: 0 };
    }

    const { id } = view ?? {};
    const { filterByViewId, filter } = linkOptions ?? {};
    const viewId = filterByViewId ?? id;
    const tableId = shareInfo.tableId;
    const result = await this.aggregationService.performRowCount(tableId, {
      viewId,
      filter,
      ...query,
    });

    return {
      rowCount: result.rowCount,
    };
  }

  async formSubmit(shareInfo: IShareViewInfo, shareViewFormSubmitRo: ShareViewFormSubmitRo) {
    const { tableId, view, shareMeta } = shareInfo;
    const { fields, typecast } = shareViewFormSubmitRo;
    if (!shareMeta?.submit?.allow) {
      throw new ForbiddenException('not allowed to submit');
    }
    if (!view) {
      throw new ForbiddenException('view is required');
    }

    const viewId = view.id;

    // check field hidden
    const visibleFields = await this.fieldService.getFieldsByQuery(tableId, {
      viewId,
      filterHidden: !view.shareMeta?.includeHiddenField,
    });
    const visibleFieldIds = visibleFields.map(({ id }) => id);
    const visibleFieldIdSet = new Set(visibleFieldIds);

    if (
      (!visibleFields.length && !isEmpty(fields)) ||
      Object.keys(fields).some((fieldId) => !visibleFieldIdSet.has(fieldId))
    ) {
      throw new ForbiddenException('The form contains hidden fields, submission not allowed.');
    }

    const { records } = await this.prismaService.$tx(async () => {
      this.cls.set('entry', { type: 'form', id: viewId });
      return this.recordOpenApiService.createRecords(tableId, {
        records: [{ fields }],
        fieldKeyType: FieldKeyType.Id,
        typecast,
      });
    });
    if (records.length === 0) {
      throw new InternalServerErrorException('The number of successful submit records is 0');
    }
    return records[0];
  }

  async copy(shareInfo: IShareViewInfo, shareViewCopyRo: IRangesRo) {
    if (!shareInfo.shareMeta?.allowCopy) {
      throw new ForbiddenException('not allowed to copy');
    }

    return this.selectionService.copy(shareInfo.tableId, {
      viewId: shareInfo.view?.id,
      ...shareViewCopyRo,
    });
  }

  private preCheckFieldHidden(view: IViewVo, fieldId: string) {
    // hidden check
    if (!view.shareMeta?.includeHiddenField && !isNotHiddenField(fieldId, view)) {
      throw new ForbiddenException('field is hidden, not allowed');
    }
  }

  async getViewLinkRecords(shareInfo: IShareViewInfo, query: IShareViewLinkRecordsRo) {
    const { tableId, view } = shareInfo;
    const { fieldId } = query;
    if (!view) {
      throw new ForbiddenException('view is required');
    }

    this.preCheckFieldHidden(view as IViewVo, fieldId);

    // link field check
    const field = await this.fieldService.getField(tableId, fieldId);
    if (field.type !== FieldType.Link) {
      throw new ForbiddenException('field type is not link field');
    }

    let recordsVo: IRecordsVo;
    if (view.type === ViewType.Form) {
      recordsVo = await this.getFormLinkRecords(field, query);
    } else if (view.type === ViewType.Plugin) {
      recordsVo =
        query.type === ShareViewLinkRecordsType.Candidate
          ? await this.getFormLinkRecords(field, query)
          : await this.getViewFilterLinkRecords(field, query);
    } else {
      recordsVo = await this.getViewFilterLinkRecords(field, query);
    }
    return recordsVo.records.map(({ id, name }) => ({ id, title: name }));
  }

  async getFormLinkRecords(field: IFieldVo, query: IShareViewLinkRecordsRo) {
    const { lookupFieldId, foreignTableId, filter, filterByViewId } =
      field.options as ILinkFieldOptions;
    const { take, skip, search } = query;

    return this.recordService.getRecords(foreignTableId, {
      viewId: filterByViewId ?? undefined,
      filter,
      take,
      skip,
      search: search ? [search, lookupFieldId] : undefined,
      projection: [lookupFieldId],
      fieldKeyType: FieldKeyType.Id,
      filterLinkCellCandidate: field.id,
    });
  }

  async getViewFilterLinkRecords(field: IFieldVo, query: IShareViewLinkRecordsRo) {
    const { fieldId, skip, take, search } = query;

    const { foreignTableId, lookupFieldId } = field.options as ILinkFieldOptions;

    return this.recordService.getRecords(foreignTableId, {
      skip,
      take,
      search: search ? [search, lookupFieldId] : undefined,
      fieldKeyType: FieldKeyType.Id,
      projection: [lookupFieldId],
      filterLinkCellSelected: fieldId,
    });
  }

  async getViewGroupPoints(
    shareInfo: IShareViewInfo,
    query?: IShareViewGroupPointsRo
  ): Promise<IGroupPointsVo> {
    if (!shareInfo.shareMeta?.includeRecords) {
      return [];
    }
    const viewId = shareInfo.view?.id;
    const tableId = shareInfo.tableId;
    const view = shareInfo.view;
    if (viewId == null) return null;

    if (view) {
      query?.groupBy?.forEach(({ fieldId }) => {
        this.preCheckFieldHidden(view, fieldId);
      });
    }

    return this.aggregationService.getGroupPoints(tableId, { ...query, viewId });
  }

  async getViewCollaborators(shareInfo: IShareViewInfo, query: IShareViewCollaboratorsRo) {
    const { view, tableId } = shareInfo;
    const { fieldId } = query;

    if (!view) {
      return this.getViewAllCollaborators(shareInfo);
    }

    // only form and kanban view can get all records
    if ([ViewType.Form, ViewType.Kanban, ViewType.Plugin].includes(view.type)) {
      return this.getViewAllCollaborators(shareInfo);
    }

    if (!fieldId) {
      throw new BadRequestException('fieldId is required');
    }

    await this.preCheckFieldHidden(view as IViewVo, fieldId);

    // user field check
    const field = await this.fieldService.getField(tableId, fieldId);
    // All user field, contains lastModifiedBy, createdBy
    if (![FieldType.User, FieldType.LastModifiedBy, FieldType.CreatedBy].includes(field.type)) {
      throw new ForbiddenException('field type is not user-related field');
    }

    return this.getViewFilterCollaborators(shareInfo, field);
  }

  private async getViewFilterUserQuery(
    tableId: string,
    filter: IFilter | undefined,
    userField: IFieldVo,
    fieldMap: Record<string, IFieldInstance>
  ) {
    const dbTableName = await this.recordService.getDbTableName(tableId);
    const queryBuilder = this.knex(dbTableName);
    const { isMultipleCellValue, dbFieldName } = userField;

    this.dbProvider.shareFilterCollaboratorsQuery(queryBuilder, dbFieldName, isMultipleCellValue);
    queryBuilder.whereNotNull(dbFieldName);
    this.dbProvider.filterQuery(queryBuilder, fieldMap, filter).appendQueryBuilder();

    return this.knex('users')
      .select('id', 'email', 'name', 'avatar')
      .from(this.knex.raw(`(${queryBuilder.toQuery()}) AS coll`))
      .leftJoin('users', 'users.id', '=', 'coll.user_id')
      .toQuery();
  }

  async getViewFilterCollaborators(shareInfo: IShareViewInfo, field: IFieldVo) {
    const { tableId, view } = shareInfo;
    if (!view) {
      throw new ForbiddenException('view is required');
    }

    const fields = await this.fieldService.getFieldsByQuery(tableId, {
      viewId: view.id,
    });

    const nativeQuery = await this.getViewFilterUserQuery(
      tableId,
      view.filter,
      field,
      fields.reduce(
        (acc, field) => {
          acc[field.id] = createFieldInstanceByVo(field);
          return acc;
        },
        {} as Record<string, IFieldInstance>
      )
    );

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

    return users.map(({ id, email, name, avatar }) => ({
      userId: id,
      email,
      userName: name,
      avatar: avatar && getFullStorageUrl(StorageAdapter.getBucket(UploadType.Avatar), avatar),
    }));
  }

  async getViewAllCollaborators(shareInfo: IShareViewInfo) {
    const { tableId, view } = shareInfo;

    if (view && ![ViewType.Form, ViewType.Kanban, ViewType.Plugin].includes(view.type)) {
      throw new ForbiddenException('view type is not allowed');
    }

    const fields = await this.fieldService.getFieldsByQuery(tableId, {
      viewId: view?.id,
      filterHidden: !view?.shareMeta?.includeHiddenField,
    });
    // If there is no user field, return an empty array
    if (
      !fields.some((field) =>
        [FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(field.type)
      )
    ) {
      return [];
    }
    const { baseId } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({
      select: { baseId: true },
      where: { id: tableId },
    });
    const list = await this.collaboratorService.getListByBase(baseId);
    return list.map((item) => pick(item, 'userId', 'email', 'userName', 'avatar'));
  }
}